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
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.env
README.md
npm-debug.log
.DS_Store
# Local backup folders
_code_backups
*.backup*
*.bak
*.old
*.orig
*.tmp
+5
View File
@@ -0,0 +1,5 @@
INITIAL_ADMIN_EMAIL=admin@example.local
INITIAL_ADMIN_PASSWORD=BitteEinLangesSicheresPasswortSetzen
SESSION_COOKIE_SECURE=false
SESSION_TTL_DAYS=30
CALENDAR_ENCRYPTION_KEY=JXNaZHH97FDgFBI2SSi04tTyu4yWwaJxb/EEyma72AM=
+25
View File
@@ -0,0 +1,25 @@
node_modules/
.next/
out/
dist/
*.tsbuildinfo
.env
.env.local
.env.*.local
/data/
*.db
*.db-journal
/uploads/
*.log
npm-debug.log*
.DS_Store
Thumbs.db
*.backup-*
*.bak-*
+49
View File
@@ -0,0 +1,49 @@
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
COPY package*.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN apk add --no-cache openssl libc6-compat \
&& addgroup -S nodejs \
&& adduser -S nextjs -G nodejs \
&& mkdir -p /data/uploads
COPY --chown=nextjs:nodejs --from=builder /app/package*.json ./
COPY --chown=nextjs:nodejs --from=builder /app/prisma ./prisma
COPY --chown=nextjs:nodejs --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs --from=builder /app/node_modules ./node_modules
RUN chown -R nextjs:nodejs /data /app
USER nextjs
EXPOSE 3000
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && npx prisma db seed && node server.js"]
+49
View File
@@ -0,0 +1,49 @@
# Personal Dashboard
Ein selbst gehostetes persönliches Dashboard mit konfigurierbaren Widgets, Multi-Tab-Support und Benutzerverwaltung.
## Features
- **Widgets:** Favoriten/Links, Notizen (Markdown), Uhr, Taschenrechner, Suche, Kalender (ICS + Exchange EWS), Domainprüfung
- **Dashboard-Tabs:** Mehrere Dashboards pro Benutzer
- **Drag & Drop:** Widgets frei im Raster positionieren und skalieren
- **Dark Mode** und anpassbares Branding (Logo, Farben, Hintergrundbild, Custom CSS)
- **Benutzerverwaltung:** Admin- und User-Rollen, Session-basierte Authentifizierung
- **Kalender:** Mehrere Quellen pro Widget, ICS-URLs und Exchange EWS mit verschlüsselten Zugangsdaten
## Tech Stack
- **Frontend:** Next.js 16, React 19, react-grid-layout
- **Backend:** Next.js API Routes, Prisma ORM
- **Datenbank:** SQLite
- **Deployment:** Docker
## Schnellstart mit Docker
```bash
cp .env.example .env
# .env anpassen (mindestens CALENDAR_ENCRYPTION_KEY setzen)
docker compose up -d
```
Das Dashboard ist dann unter `http://localhost:3130` erreichbar.
## Entwicklung
```bash
npm install
npx prisma generate
npx prisma db push
npm run dev
```
## Umgebungsvariablen
| Variable | Beschreibung | Standard |
|---|---|---|
| `DATABASE_URL` | SQLite-Pfad | `file:/data/dashboard.db` |
| `INITIAL_ADMIN_EMAIL` | E-Mail des initialen Admins | `admin@example.local` |
| `INITIAL_ADMIN_PASSWORD` | Passwort des initialen Admins | (muss gesetzt werden) |
| `CALENDAR_ENCRYPTION_KEY` | 32-Byte-Hex-Key für Kalender-Passwörter | (muss gesetzt werden) |
| `CALENDAR_ALLOWED_HOSTS` | Erlaubte Kalender-Hosts (kommasepariert) | (leer = alle) |
| `SESSION_TTL_DAYS` | Session-Lebensdauer in Tagen | `30` |
+31
View File
@@ -0,0 +1,31 @@
services:
personal-dashboard:
container_name: personal-dashboard
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: "production"
NEXT_TELEMETRY_DISABLED: "1"
DATABASE_URL: "file:/data/dashboard.db"
HOSTNAME: "0.0.0.0"
PORT: "3130"
SESSION_TTL_DAYS: "30"
SESSION_COOKIE_NAME: "personal_dashboard_session"
SESSION_COOKIE_SECURE: "false"
INITIAL_ADMIN_EMAIL: "${INITIAL_ADMIN_EMAIL:-admin@example.local}"
INITIAL_ADMIN_PASSWORD: "${INITIAL_ADMIN_PASSWORD:-BitteEinLangesSicheresPasswortSetzen}"
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:?CALENDAR_ENCRYPTION_KEY fehlt in .env}"
CALENDAR_ALLOWED_HOSTS: "${CALENDAR_ALLOWED_HOSTS:-}"
ALLOW_INSECURE_CALENDAR_HTTP: "${ALLOW_INSECURE_CALENDAR_HTTP:-false}"
ALLOW_INSECURE_EXCHANGE_HTTP: "${ALLOW_INSECURE_EXCHANGE_HTTP:-false}"
ports:
- "3130:3000"
volumes:
- personal-dashboard-data:/data
volumes:
personal-dashboard-data:
+6
View File
@@ -0,0 +1,6 @@
const nextConfig = {
output: "standalone",
reactStrictMode: true
};
export default nextConfig;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "personal-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:seed": "prisma db seed"
},
"dependencies": {
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.2",
"fast-xml-parser": "^5.2.5",
"httpntlm": "^1.8.13",
"next": "16.0.0",
"node-ical": "^0.22.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-grid-layout": "2.2.3",
"react-resizable": "3.1.3"
},
"devDependencies": {
"@types/node": "^24.8.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"prisma": "^6.19.3",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "node prisma/seed.mjs"
}
});
+184
View File
@@ -0,0 +1,184 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String?
profileImageUrl String?
role UserRole @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
favorites Favorite[]
settings Settings?
tabs DashboardTab[]
widgets Widget[]
notes NoteBoardItem[]
calendarSources CalendarSource[]
calendarWidgetSources CalendarWidgetSource[]
}
model Session {
id String @id @default(cuid())
tokenHash String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model DashboardTab {
id String @id @default(cuid())
userId String
title String @default("Dashboard")
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
widgets Widget[]
@@index([userId])
@@index([userId, position])
}
model Favorite {
id String @id @default(cuid())
userId String
widgetId String?
title String
url String
iconUrl String?
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
widget Widget? @relation(fields: [widgetId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([widgetId])
@@index([userId, widgetId])
}
model Settings {
id String @id @default(cuid())
userId String @unique
darkMode Boolean @default(false)
calendarIcsUrl String?
calendarMaxEvents Int @default(8)
calendarLookaheadDays Int @default(60)
dashboardTitle String @default("Personal Dashboard")
dashboardSubtitle String?
logoUrl String? @default("/logo.svg")
faviconUrl String? @default("/favicon.ico")
backgroundImageUrl String?
backgroundImageOpacity Int @default(0)
primaryColor String @default("#2563eb")
secondaryColor String @default("#dbeafe")
customCss String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Widget {
id String @id @default(cuid())
userId String
tabId String?
type String
title String
position Int @default(0)
x Int @default(0)
y Int @default(0)
w Int @default(4)
h Int @default(4)
opacity Int @default(100)
viewMode String @default("list")
fontSize Int @default(100)
calendarNextEventsCount Int @default(3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tab DashboardTab? @relation(fields: [tabId], references: [id], onDelete: Cascade)
favorites Favorite[]
calendarWidgetSources CalendarWidgetSource[]
@@index([userId])
@@index([tabId])
@@index([userId, tabId])
@@index([userId, position])
}
model NoteBoardItem {
id String @id @default(cuid())
userId String
type String @default("note")
title String
content String @default("")
x Int @default(0)
y Int @default(0)
w Int @default(3)
h Int @default(3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model CalendarSource {
id String @id @default(cuid())
userId String
widgetId String?
type String @default("ICS")
name String @default("Kalender")
color String @default("#2563eb")
nextEventsCount Int @default(3)
icsUrl String?
exchangeEwsUrl String?
exchangeMailbox String?
exchangeUsername String?
exchangeDomain String?
exchangePasswordEnc String?
exchangePasswordIv String?
exchangePasswordTag String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
widgetLinks CalendarWidgetSource[]
@@index([userId])
@@index([widgetId])
}
model CalendarWidgetSource {
id String @id @default(cuid())
userId String
widgetId String
sourceId String
position Int @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade)
source CalendarSource @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@unique([widgetId, sourceId])
@@index([userId])
@@index([widgetId])
@@index([sourceId])
}
enum UserRole {
ADMIN
USER
}
+106
View File
@@ -0,0 +1,106 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
function normalizeEmail(value) {
const email = String(value ?? "").trim().toLowerCase();
if (!email) {
return "admin@example.local";
}
return email;
}
function normalizePassword(value) {
const password = String(value ?? "").trim();
if (!password) {
return "BitteEinLangesSicheresPasswortSetzen";
}
return password;
}
async function ensureUserSettings(userId, email) {
const existingSettings = await prisma.settings.findUnique({
where: {
userId
}
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarIcsUrl: null,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
dashboardSubtitle: email,
logoUrl: "/logo.svg",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe"
}
});
}
async function main() {
const initialAdminEmail = normalizeEmail(process.env.INITIAL_ADMIN_EMAIL);
const initialAdminPassword = normalizePassword(process.env.INITIAL_ADMIN_PASSWORD);
const existingAdmin = await prisma.user.findUnique({
where: {
email: initialAdminEmail
},
select: {
id: true,
email: true,
role: true
}
});
if (existingAdmin) {
await ensureUserSettings(existingAdmin.id, existingAdmin.email);
console.log("Initialer Admin existiert bereits. Seed übersprungen.");
return;
}
const passwordHash = await bcrypt.hash(initialAdminPassword, 12);
const admin = await prisma.user.create({
data: {
email: initialAdminEmail,
passwordHash,
displayName: "Admin",
profileImageUrl: null,
role: "ADMIN"
},
select: {
id: true,
email: true
}
});
await ensureUserSettings(admin.id, admin.email);
console.log("Initialer Admin wurde erstellt.");
console.log(`E-Mail: ${admin.email}`);
console.log("Dashboard startet leer. Es wurden keine Widgets und keine Favoriten angelegt.");
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
View File
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1400" viewBox="0 0 2400 1400">
<defs>
<radialGradient id="g1" cx="20%" cy="20%" r="70%">
<stop offset="0%" stop-color="#2563eb" stop-opacity="0.55"/>
<stop offset="45%" stop-color="#111827" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#020617" stop-opacity="1"/>
</radialGradient>
<radialGradient id="g2" cx="80%" cy="30%" r="55%">
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.45"/>
<stop offset="55%" stop-color="#0f172a" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#020617" stop-opacity="0"/>
</radialGradient>
<radialGradient id="g3" cx="60%" cy="90%" r="60%">
<stop offset="0%" stop-color="#06b6d4" stop-opacity="0.28"/>
<stop offset="70%" stop-color="#020617" stop-opacity="0"/>
</radialGradient>
<pattern id="grid" width="56" height="56" patternUnits="userSpaceOnUse">
<path d="M 56 0 L 0 0 0 56" fill="none" stroke="#ffffff" stroke-opacity="0.055" stroke-width="1"/>
</pattern>
</defs>
<rect width="2400" height="1400" fill="#020617"/>
<rect width="2400" height="1400" fill="url(#g1)"/>
<rect width="2400" height="1400" fill="url(#g2)"/>
<rect width="2400" height="1400" fill="url(#g3)"/>
<rect width="2400" height="1400" fill="url(#grid)"/>
<circle cx="390" cy="260" r="240" fill="#60a5fa" opacity="0.08"/>
<circle cx="1830" cy="330" r="320" fill="#a855f7" opacity="0.08"/>
<circle cx="1430" cy="1160" r="380" fill="#22d3ee" opacity="0.06"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="28" fill="#2563eb"/>
<path d="M34 38h60v14H34V38zm0 25h60v14H34V63zm0 25h38v14H34V88z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

+147
View File
@@ -0,0 +1,147 @@
/* Admin-Übersicht */
.adminOverviewGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
margin-top: 18px;
}
.adminOverviewCard {
display: grid;
gap: 6px;
padding: 16px;
color: var(--text);
text-decoration: none;
background: color-mix(in srgb, var(--surface) 88%, transparent);
border: 1px solid var(--border);
border-radius: 16px;
}
.adminOverviewCard:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.adminOverviewCard span {
color: var(--muted);
line-height: 1.35;
}
/* Benutzerverwaltung */
.adminPanelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.adminUsersTableWrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 16px;
}
.adminUsersTable {
width: 100%;
min-width: 920px;
border-collapse: collapse;
color: var(--text);
}
.adminUsersTable th,
.adminUsersTable td {
padding: 10px 12px;
text-align: left;
vertical-align: middle;
border-bottom: 1px solid var(--border);
}
.adminUsersTable th {
color: var(--muted);
background: color-mix(in srgb, var(--surface-strong) 86%, transparent);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.adminUsersTable tbody tr:last-child td {
border-bottom: 0;
}
.adminUsersTable tbody tr:hover td {
background: color-mix(in srgb, var(--accent-soft) 18%, transparent);
}
.adminUsersActionsHeader,
.adminUsersActionsCell {
width: 220px;
text-align: right !important;
}
.adminUsersActionButtons,
.adminUsersInlineForm {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.adminTableButton {
min-height: 34px;
padding: 0 12px;
white-space: nowrap;
}
.adminTableInput {
height: 34px;
min-height: 34px;
padding: 0 10px;
}
.adminUserId {
color: var(--muted);
font-size: 12px;
word-break: break-all;
}
.adminUserBadge {
display: inline-flex;
align-items: center;
height: 20px;
margin-left: 8px;
padding: 0 7px;
color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: 999px;
font-size: 11px;
font-weight: 800;
}
.buttonDanger {
color: #fecaca;
background: color-mix(in srgb, #dc2626 18%, var(--surface));
border-color: #ef4444;
}
.buttonDanger:hover {
background: color-mix(in srgb, #dc2626 28%, var(--surface));
}
.buttonDanger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 760px) {
.adminUsersTable {
min-width: 760px;
}
.adminUsersTable th,
.adminUsersTable td {
padding: 9px 10px;
}
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
type Settings = {
darkMode: boolean;
};
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;
}
export default function AdminPage() {
const [settings, setSettings] = useState<Settings | null>(null);
useEffect(() => {
async function loadSettings() {
try {
const response = await fetch("/api/settings", {
cache: "no-store"
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
setSettings(data.settings);
} catch {
setSettings(null);
}
}
void loadSettings();
}, []);
return (
<main className={settings?.darkMode ? "app appDark" : "app"}>
<header className="adminTopBar">
<div className="brandBlock">
<div className="brandText">
<div className="title">Administration</div>
<div className="subtitle">Systemverwaltung</div>
</div>
</div>
<div className="adminTopActions">
<a className="button buttonSecondary" href="/">
Zum Dashboard
</a>
</div>
</header>
<section className="adminShell">
<div className="adminPanel">
<h1>Administration</h1>
<div className="adminOverviewGrid">
<a className="adminOverviewCard" href="/admin/users">
<strong>Benutzer verwalten</strong>
<span>Name und E-Mail bearbeiten oder Benutzer inklusive Einstellungen, Widgets und Uploads löschen.</span>
</a>
<a className="adminOverviewCard" href="/settings">
<strong>Meine Einstellungen</strong>
<span>Dashboard-Design, Logo, Hintergrund, Farben und eigene Einstellungen anpassen.</span>
</a>
</div>
</div>
</section>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
export default function AdminSettingsRedirectPage() {
useEffect(() => {
window.location.replace("/settings");
}, []);
return (
<main className="app">
<section className="adminShell">
<div className="adminPanel">
<h1>Weiterleitung</h1>
<p className="muted">Du wirst zu den Einstellungen weitergeleitet.</p>
<a className="button adminInlineButton" href="/settings">
Einstellungen öffnen
</a>
</div>
</section>
</main>
);
}
+394
View File
@@ -0,0 +1,394 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
type AdminUser = {
id: string;
email: string;
displayName: string | null;
role: "ADMIN" | "USER";
createdAt: string;
updatedAt: string;
};
type CurrentUser = {
id: string;
email: string;
displayName: string | null;
role: "ADMIN" | "USER";
};
type Settings = {
darkMode: boolean;
};
type UserDraft = {
email: string;
displayName: string;
};
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 formatUserRole(role: AdminUser["role"]) {
return role === "ADMIN" ? "Administrator" : "Benutzer";
}
export default function AdminUsersPage() {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [settings, setSettings] = useState<Settings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editingById, setEditingById] = useState<Record<string, UserDraft>>({});
const [loading, setLoading] = useState(true);
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadData();
}, []);
async function loadData() {
setLoading(true);
setError(null);
try {
const [meResponse, usersResponse, settingsResponse] = await Promise.all([
fetch("/api/auth/me", {
cache: "no-store"
}),
fetch("/api/users", {
cache: "no-store"
}),
fetch("/api/settings", {
cache: "no-store"
})
]);
const meData = await parseJsonResponse<{ user: CurrentUser | null }>(meResponse);
const usersData = await parseJsonResponse<{ users: AdminUser[] }>(usersResponse);
const settingsData = await parseJsonResponse<{ settings: Settings }>(settingsResponse);
setCurrentUser(meData.user);
setSettings(settingsData.settings);
setUsers(usersData.users);
setEditingById(
Object.fromEntries(
usersData.users.map((user) => [
user.id,
{
email: user.email,
displayName: user.displayName ?? ""
}
])
)
);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Benutzer konnten nicht geladen werden.");
} finally {
setLoading(false);
}
}
function updateDraft(userId: string, patch: Partial<UserDraft>) {
setEditingById((current) => ({
...current,
[userId]: {
email: current[userId]?.email ?? "",
displayName: current[userId]?.displayName ?? "",
...patch
}
}));
}
function startEditing(user: AdminUser) {
setEditingUserId(user.id);
setEditingById((current) => ({
...current,
[user.id]: {
email: user.email,
displayName: user.displayName ?? ""
}
}));
setMessage(null);
setError(null);
}
function cancelEditing(user: AdminUser) {
setEditingUserId(null);
setEditingById((current) => ({
...current,
[user.id]: {
email: user.email,
displayName: user.displayName ?? ""
}
}));
}
async function saveUser(event: FormEvent<HTMLFormElement>, userId: string) {
event.preventDefault();
const draft = editingById[userId];
if (!draft) {
return;
}
setSavingUserId(userId);
setError(null);
setMessage(null);
try {
const response = await fetch(`/api/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email: draft.email,
displayName: draft.displayName
})
});
const data = await parseJsonResponse<{ user: AdminUser }>(response);
setUsers((current) => current.map((user) => (user.id === data.user.id ? data.user : user)));
setEditingById((current) => ({
...current,
[data.user.id]: {
email: data.user.email,
displayName: data.user.displayName ?? ""
}
}));
if (currentUser?.id === data.user.id) {
setCurrentUser({
id: data.user.id,
email: data.user.email,
displayName: data.user.displayName,
role: data.user.role
});
}
setEditingUserId(null);
setMessage("Benutzer gespeichert.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Benutzer konnte nicht gespeichert werden.");
} finally {
setSavingUserId(null);
}
}
async function deleteUser(user: AdminUser) {
const confirmed = window.confirm(
`Benutzer "${user.email}" wirklich komplett löschen?\n\nDabei werden auch Einstellungen, Widgets, Tabs, Kalender, Favoriten, Sessions und Uploads dieses Benutzers gelöscht.`
);
if (!confirmed) {
return;
}
setDeletingUserId(user.id);
setError(null);
setMessage(null);
try {
const response = await fetch(`/api/users/${encodeURIComponent(user.id)}`, {
method: "DELETE"
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? "Benutzer konnte nicht gelöscht werden.");
}
setUsers((current) => current.filter((currentUserItem) => currentUserItem.id !== user.id));
setEditingById((current) => {
const next = { ...current };
delete next[user.id];
return next;
});
setMessage("Benutzer gelöscht.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Benutzer konnte nicht gelöscht werden.");
} finally {
setDeletingUserId(null);
}
}
const adminCount = users.filter((user) => user.role === "ADMIN").length;
return (
<main className={settings?.darkMode ? "app appDark" : "app"}>
<header className="adminTopBar">
<div className="brandBlock">
<div className="brandText">
<div className="title">Benutzerverwaltung</div>
<div className="subtitle">Benutzer bearbeiten und löschen</div>
</div>
</div>
<div className="adminTopActions">
<a className="button buttonSecondary" href="/admin">
Zur Administration
</a>
<a className="button buttonSecondary" href="/">
Zum Dashboard
</a>
</div>
</header>
<section className="adminShell">
<div className="adminPanel">
<div className="adminPanelHeader">
<div>
<h1>Benutzer</h1>
<p className="muted">Name und E-Mail bearbeiten. Die Datenbank-ID bleibt unverändert.</p>
</div>
</div>
{loading ? <p className="muted">Lade Benutzer...</p> : null}
{error ? <p className="errorText">{error}</p> : null}
{message ? <p className="successText">{message}</p> : null}
{!loading && users.length === 0 ? <p className="muted">Keine Benutzer vorhanden.</p> : null}
{!loading && users.length > 0 ? (
<div className="adminUsersTableWrap">
<table className="adminUsersTable">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>ID</th>
<th className="adminUsersActionsHeader">Aktionen</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const draft = editingById[user.id] ?? {
email: user.email,
displayName: user.displayName ?? ""
};
const isEditing = editingUserId === user.id;
const isCurrentUser = currentUser?.id === user.id;
const isLastAdmin = user.role === "ADMIN" && adminCount <= 1;
const deleteDisabled = isCurrentUser || isLastAdmin || deletingUserId === user.id;
return (
<tr key={user.id}>
<td>
{isEditing ? (
<input
className="input adminTableInput"
value={draft.displayName}
onChange={(event) => updateDraft(user.id, { displayName: event.target.value })}
placeholder="Name optional"
maxLength={120}
/>
) : (
<span>{user.displayName || "—"}</span>
)}
</td>
<td>
{isEditing ? (
<input
className="input adminTableInput"
type="email"
value={draft.email}
onChange={(event) => updateDraft(user.id, { email: event.target.value })}
required
/>
) : (
<strong>{user.email}</strong>
)}
{isCurrentUser ? <span className="adminUserBadge">Du</span> : null}
</td>
<td>{formatUserRole(user.role)}</td>
<td>
<code className="adminUserId">{user.id}</code>
</td>
<td className="adminUsersActionsCell">
{isEditing ? (
<form className="adminUsersInlineForm" onSubmit={(event) => void saveUser(event, user.id)}>
<button type="submit" className="button adminTableButton" disabled={savingUserId === user.id}>
{savingUserId === user.id ? "Speichere..." : "Speichern"}
</button>
<button
type="button"
className="button buttonSecondary adminTableButton"
onClick={() => cancelEditing(user)}
disabled={savingUserId === user.id}
>
Abbrechen
</button>
</form>
) : (
<div className="adminUsersActionButtons">
<button
type="button"
className="button buttonSecondary adminTableButton"
onClick={() => startEditing(user)}
>
Bearbeiten
</button>
<button
type="button"
className="button buttonDanger adminTableButton"
onClick={() => void deleteUser(user)}
disabled={deleteDisabled}
title={
isCurrentUser
? "Du kannst deinen eigenen Benutzer nicht löschen"
: isLastAdmin
? "Der letzte Administrator kann nicht gelöscht werden"
: "Benutzer löschen"
}
>
{deletingUserId === user.id ? "Lösche..." : "Löschen"}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
</section>
</main>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizeDisplayName, normalizeEmail, normalizePassword } from "@/lib/validation";
function sanitizeUser(user: {
id: string;
email: string;
displayName: string | null;
profileImageUrl: string | null;
role: string;
createdAt: Date;
}) {
return {
id: user.id,
email: user.email,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
role: user.role,
createdAt: user.createdAt
};
}
async function requireAdmin() {
const user = await requireCurrentUser();
if (user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
return user;
}
async function ensureUserSettings(userId: string, email: string) {
const existingSettings = await prisma.settings.findUnique({
where: {
userId
}
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarIcsUrl: null,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
dashboardSubtitle: email,
logoUrl: "/logo.svg",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe"
}
});
}
export async function GET() {
try {
await requireAdmin();
const users = await prisma.user.findMany({
orderBy: [
{
createdAt: "asc"
}
],
select: {
id: true,
email: true,
displayName: true,
profileImageUrl: true,
role: true,
createdAt: true
}
});
return NextResponse.json({
users: users.map(sanitizeUser)
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Nutzer konnten nicht geladen werden." },
{ status: 403 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = (await request.json().catch(() => null)) as {
email?: unknown;
password?: unknown;
displayName?: unknown;
role?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const email = normalizeEmail(body.email);
const password = normalizePassword(body.password);
const displayName = normalizeDisplayName(body.displayName);
const role = body.role === "ADMIN" ? "ADMIN" : "USER";
if (!email) {
return NextResponse.json({ error: "E-Mail-Adresse ist ungültig." }, { status: 400 });
}
if (!password) {
return NextResponse.json(
{ error: "Das Passwort muss 10 bis 128 Zeichen lang sein." },
{ status: 400 }
);
}
const existingUser = await prisma.user.findUnique({
where: {
email
},
select: {
id: true
}
});
if (existingUser) {
return NextResponse.json({ error: "Diese E-Mail-Adresse wird bereits verwendet." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName,
profileImageUrl: null,
role
},
select: {
id: true,
email: true,
displayName: true,
profileImageUrl: true,
role: true,
createdAt: true
}
});
await ensureUserSettings(user.id, user.email);
return NextResponse.json(
{
user: sanitizeUser(user)
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Nutzer konnte nicht erstellt werden." },
{ status: 500 }
);
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { createSession } from "@/lib/auth";
import { normalizeEmail, normalizePassword } from "@/lib/validation";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as {
email?: unknown;
password?: unknown;
} | null;
const email = normalizeEmail(body?.email);
const password = normalizePassword(body?.password);
if (!email || !password) {
return NextResponse.json(
{
error: "E-Mail oder Passwort ungültig."
},
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: {
email
},
select: {
id: true,
email: true,
displayName: true,
role: true,
passwordHash: true
}
});
if (!user) {
return NextResponse.json(
{
error: "E-Mail oder Passwort falsch."
},
{ status: 401 }
);
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
return NextResponse.json(
{
error: "E-Mail oder Passwort falsch."
},
{ status: 401 }
);
}
await createSession(user.id);
return NextResponse.json({
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role
}
});
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { destroyCurrentSession } from "@/lib/auth";
export async function POST() {
await destroyCurrentSession();
return NextResponse.json({
ok: true
});
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
export async function GET() {
const user = await getCurrentUser();
return NextResponse.json({
user
});
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { fetchExchangeEwsEvents, fetchIcsEvents } from "@/lib/calendar-fetchers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function normalizeInteger(value: unknown, fallback: number, min: number, max: number): number {
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.round(numberValue)));
}
export async function GET(request: NextRequest) {
try {
const user = await requireCurrentUser();
const widgetId = request.nextUrl.searchParams.get("widgetId");
const settings = await prisma.settings.findUnique({
where: {
userId: user.id
}
});
const configuredMaxEvents = normalizeInteger(settings?.calendarMaxEvents, 8, 1, 50);
const lookaheadDays = normalizeInteger(settings?.calendarLookaheadDays, 60, 1, 365);
if (!widgetId) {
return NextResponse.json({
events: [],
error: null
});
}
const widget = await prisma.widget.findFirst({
where: {
id: widgetId,
userId: user.id,
type: "calendar"
}
});
if (!widget) {
return NextResponse.json({ error: "Kalender-Widget nicht gefunden." }, { status: 404 });
}
const selectedLinks = await prisma.calendarWidgetSource.findMany({
where: {
userId: user.id,
widgetId
},
include: {
source: true
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
if (selectedLinks.length === 0) {
return NextResponse.json({
events: [],
error: null
});
}
const nextEventsCount = normalizeInteger(widget.calendarNextEventsCount, 3, 0, 10);
const maxEventsPerSource = Math.max(configuredMaxEvents, nextEventsCount, 1);
const eventResults = await Promise.allSettled(
selectedLinks.map(async (link) => {
const source = link.source;
const events =
source.type === "EXCHANGE_EWS"
? await fetchExchangeEwsEvents(source, lookaheadDays, maxEventsPerSource)
: await fetchIcsEvents(source, lookaheadDays, maxEventsPerSource);
return events.map((event) => ({
...event,
sourceId: source.id,
sourceName: source.name,
sourceColor: source.color
}));
})
);
const events = eventResults
.flatMap((result) => (result.status === "fulfilled" ? result.value : []))
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
const firstError = eventResults.find((result) => result.status === "rejected");
return NextResponse.json({
events,
error: firstError?.status === "rejected" ? firstError.reason?.message ?? "Ein Kalender konnte nicht geladen werden." : null
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({
events: [],
error: error instanceof Error ? error.message : "Kalender konnte nicht geladen werden."
});
}
}
+589
View File
@@ -0,0 +1,589 @@
import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { encryptSecret } from "@/lib/secret-crypto";
type CalendarSourceType = "ICS" | "EXCHANGE_EWS";
function normalizeSourceType(value: unknown): CalendarSourceType | null {
if (value === "ICS" || value === "EXCHANGE_EWS") {
return value;
}
return null;
}
function normalizeText(value: unknown, maxLength: number): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, maxLength);
}
function normalizeOptionalText(value: unknown, maxLength: number): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, maxLength);
}
function normalizeColor(value: unknown): string {
if (typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value.trim())) {
return value.trim();
}
return "#2563eb";
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number): number {
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.round(numberValue)));
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
function getAllowedHosts(): Set<string> {
return new Set(
(process.env.CALENDAR_ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean)
);
}
function isBlockedHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "0.0.0.0" ||
host.startsWith("127.") ||
host.startsWith("169.254.")
);
}
function normalizeHttpUrl(value: unknown, options: { allowHttpEnvName: string }): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
try {
const parsedUrl = new URL(cleanValue);
const allowHttp = process.env[options.allowHttpEnvName] === "true";
if (parsedUrl.protocol !== "https:" && !(allowHttp && parsedUrl.protocol === "http:")) {
return null;
}
if (parsedUrl.username || parsedUrl.password) {
return null;
}
if (isBlockedHost(parsedUrl.hostname)) {
return null;
}
const allowedHosts = getAllowedHosts();
if (allowedHosts.size > 0 && !allowedHosts.has(parsedUrl.hostname.toLowerCase())) {
return null;
}
return parsedUrl.toString().slice(0, 1000);
} catch {
return null;
}
}
function sanitizeSource(source: any) {
if (!source) {
return null;
}
return {
id: source.id,
userId: source.userId,
type: source.type,
name: source.name,
color: source.color,
nextEventsCount: source.nextEventsCount ?? 3,
icsUrl: source.icsUrl,
exchangeEwsUrl: source.exchangeEwsUrl,
exchangeMailbox: source.exchangeMailbox,
exchangeUsername: source.exchangeUsername,
exchangeDomain: source.exchangeDomain,
passwordConfigured: Boolean(source.exchangePasswordEnc && source.exchangePasswordIv && source.exchangePasswordTag)
};
}
async function requireCalendarWidget(userId: string, widgetId: string) {
const widget = await prisma.widget.findFirst({
where: {
id: widgetId,
userId,
type: "calendar"
}
});
if (!widget) {
throw new Error("Kalender-Widget nicht gefunden.");
}
return widget;
}
async function listSources(userId: string) {
const sources = await prisma.calendarSource.findMany({
where: {
userId
},
orderBy: [
{
name: "asc"
},
{
createdAt: "asc"
}
]
});
return sources.map(sanitizeSource).filter(Boolean);
}
export async function GET(request: NextRequest) {
try {
const user = await requireCurrentUser();
const widgetId = request.nextUrl.searchParams.get("widgetId")?.trim() || null;
const sources = await listSources(user.id);
if (!widgetId) {
return NextResponse.json({
sources
});
}
const widget = await requireCalendarWidget(user.id, widgetId);
const selectedLinks = await prisma.calendarWidgetSource.findMany({
where: {
userId: user.id,
widgetId
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
return NextResponse.json({
sources,
selectedSourceIds: selectedLinks.map((link) => link.sourceId),
nextEventsCount: widget.calendarNextEventsCount ?? 3
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Kalenderquellen konnten nicht geladen werden." },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
const body = (await request.json().catch(() => null)) as {
type?: unknown;
name?: unknown;
color?: unknown;
nextEventsCount?: unknown;
icsUrl?: unknown;
exchangeEwsUrl?: unknown;
exchangeMailbox?: unknown;
exchangeUsername?: unknown;
exchangeDomain?: unknown;
exchangePassword?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const type = normalizeSourceType(body.type);
if (!type) {
return NextResponse.json({ error: "Kalendertyp ist ungültig." }, { status: 400 });
}
const name = normalizeText(body.name, 120) ?? "Kalender";
const color = normalizeColor(body.color);
const nextEventsCount = normalizeInteger(body.nextEventsCount, 3, 0, 10);
let data: any = {
userId: user.id,
widgetId: null,
type,
name,
color,
nextEventsCount
};
if (type === "ICS") {
const icsUrl = normalizeHttpUrl(body.icsUrl, {
allowHttpEnvName: "ALLOW_INSECURE_CALENDAR_HTTP"
});
if (!icsUrl) {
return NextResponse.json({ error: "ICS-URL ist ungültig oder nicht erlaubt." }, { status: 400 });
}
data = {
...data,
icsUrl,
exchangeEwsUrl: null,
exchangeMailbox: null,
exchangeUsername: null,
exchangeDomain: null,
exchangePasswordEnc: null,
exchangePasswordIv: null,
exchangePasswordTag: null
};
}
if (type === "EXCHANGE_EWS") {
const exchangeEwsUrl = normalizeHttpUrl(body.exchangeEwsUrl, {
allowHttpEnvName: "ALLOW_INSECURE_EXCHANGE_HTTP"
});
const exchangeUsername = normalizeText(body.exchangeUsername, 200);
const exchangeMailbox = normalizeOptionalText(body.exchangeMailbox, 320);
const exchangeDomain = normalizeOptionalText(body.exchangeDomain, 120);
const password = typeof body.exchangePassword === "string" ? body.exchangePassword : "";
if (!exchangeEwsUrl) {
return NextResponse.json({ error: "Exchange-EWS-URL ist ungültig oder nicht erlaubt." }, { status: 400 });
}
if (!exchangeUsername) {
return NextResponse.json({ error: "Exchange-Benutzername fehlt." }, { status: 400 });
}
if (!password.trim()) {
return NextResponse.json({ error: "Exchange-Passwort fehlt." }, { status: 400 });
}
const encryptedPassword = encryptSecret(password);
data = {
...data,
icsUrl: null,
exchangeEwsUrl,
exchangeMailbox,
exchangeUsername,
exchangeDomain,
exchangePasswordEnc: encryptedPassword.encrypted,
exchangePasswordIv: encryptedPassword.iv,
exchangePasswordTag: encryptedPassword.tag
};
}
const source = await prisma.calendarSource.create({
data
});
return NextResponse.json(
{
source: sanitizeSource(source)
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Kalenderquelle konnte nicht erstellt werden." },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const user = await requireCurrentUser();
const body = (await request.json().catch(() => null)) as {
sourceId?: unknown;
widgetId?: unknown;
sourceIds?: unknown;
nextEventsCount?: unknown;
type?: unknown;
name?: unknown;
color?: unknown;
icsUrl?: unknown;
exchangeEwsUrl?: unknown;
exchangeMailbox?: unknown;
exchangeUsername?: unknown;
exchangeDomain?: unknown;
exchangePassword?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
if (typeof body.widgetId === "string") {
const widgetId = body.widgetId.trim();
const widget = await requireCalendarWidget(user.id, widgetId);
const sourceIds = normalizeStringArray(body.sourceIds);
const userSources = await prisma.calendarSource.findMany({
where: {
userId: user.id,
id: {
in: sourceIds
}
},
select: {
id: true
}
});
const allowedSourceIds = userSources.map((source) => source.id);
const nextEventsCount = normalizeInteger(body.nextEventsCount, widget.calendarNextEventsCount ?? 3, 0, 10);
await prisma.$transaction(async (tx) => {
await tx.widget.update({
where: {
id: widget.id
},
data: {
calendarNextEventsCount: nextEventsCount
}
});
await tx.calendarWidgetSource.deleteMany({
where: {
userId: user.id,
widgetId
}
});
await Promise.all(
allowedSourceIds.map((sourceId, index) =>
tx.calendarWidgetSource.create({
data: {
userId: user.id,
widgetId,
sourceId,
position: index
}
})
)
);
});
return NextResponse.json({
selectedSourceIds: allowedSourceIds,
nextEventsCount
});
}
if (typeof body.sourceId !== "string") {
return NextResponse.json({ error: "sourceId fehlt." }, { status: 400 });
}
const sourceId = body.sourceId.trim();
const existingSource = await prisma.calendarSource.findFirst({
where: {
id: sourceId,
userId: user.id
}
});
if (!existingSource) {
return NextResponse.json({ error: "Kalenderquelle nicht gefunden." }, { status: 404 });
}
const type = normalizeSourceType(body.type);
if (!type) {
return NextResponse.json({ error: "Kalendertyp ist ungültig." }, { status: 400 });
}
const name = normalizeText(body.name, 120) ?? existingSource.name;
const color = normalizeColor(body.color);
const nextEventsCount = normalizeInteger(body.nextEventsCount, existingSource.nextEventsCount ?? 3, 0, 10);
let data: any = {
type,
name,
color,
nextEventsCount
};
if (type === "ICS") {
const icsUrl = normalizeHttpUrl(body.icsUrl, {
allowHttpEnvName: "ALLOW_INSECURE_CALENDAR_HTTP"
});
if (!icsUrl) {
return NextResponse.json({ error: "ICS-URL ist ungültig oder nicht erlaubt." }, { status: 400 });
}
data = {
...data,
icsUrl,
exchangeEwsUrl: null,
exchangeMailbox: null,
exchangeUsername: null,
exchangeDomain: null,
exchangePasswordEnc: null,
exchangePasswordIv: null,
exchangePasswordTag: null
};
}
if (type === "EXCHANGE_EWS") {
const exchangeEwsUrl = normalizeHttpUrl(body.exchangeEwsUrl, {
allowHttpEnvName: "ALLOW_INSECURE_EXCHANGE_HTTP"
});
const exchangeUsername = normalizeText(body.exchangeUsername, 200);
const exchangeMailbox = normalizeOptionalText(body.exchangeMailbox, 320);
const exchangeDomain = normalizeOptionalText(body.exchangeDomain, 120);
const password = typeof body.exchangePassword === "string" ? body.exchangePassword : "";
if (!exchangeEwsUrl) {
return NextResponse.json({ error: "Exchange-EWS-URL ist ungültig oder nicht erlaubt." }, { status: 400 });
}
if (!exchangeUsername) {
return NextResponse.json({ error: "Exchange-Benutzername fehlt." }, { status: 400 });
}
data = {
...data,
icsUrl: null,
exchangeEwsUrl,
exchangeMailbox,
exchangeUsername,
exchangeDomain
};
if (password.trim()) {
const encryptedPassword = encryptSecret(password);
data.exchangePasswordEnc = encryptedPassword.encrypted;
data.exchangePasswordIv = encryptedPassword.iv;
data.exchangePasswordTag = encryptedPassword.tag;
} else if (!existingSource.exchangePasswordEnc) {
return NextResponse.json({ error: "Exchange-Passwort fehlt." }, { status: 400 });
}
}
const source = await prisma.calendarSource.update({
where: {
id: existingSource.id
},
data
});
return NextResponse.json({
source: sanitizeSource(source)
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Kalenderquelle konnte nicht gespeichert werden." },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await requireCurrentUser();
const sourceId = request.nextUrl.searchParams.get("sourceId")?.trim() || null;
if (!sourceId) {
return NextResponse.json({ error: "sourceId fehlt." }, { status: 400 });
}
const existingSource = await prisma.calendarSource.findFirst({
where: {
id: sourceId,
userId: user.id
},
select: {
id: true
}
});
if (!existingSource) {
return NextResponse.json({ error: "Kalenderquelle nicht gefunden." }, { status: 404 });
}
await prisma.calendarSource.delete({
where: {
id: existingSource.id
}
});
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Kalenderquelle konnte nicht gelöscht werden." }, { status: 500 });
}
}
+535
View File
@@ -0,0 +1,535 @@
import net from "node:net";
import { domainToASCII } from "url";
import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type RdapBootstrap = {
services: Array<[string[], string[]]>;
};
type CachedBootstrap = {
expiresAt: number;
data: RdapBootstrap;
};
type DomainCheckStatus = "available" | "registered" | "invalid" | "unknown";
let cachedBootstrap: CachedBootstrap | null = null;
const whoisTimeoutMs = 10000;
function normalizeDomain(value: string): {
valid: boolean;
domain?: string;
asciiDomain?: string;
tld?: string;
error?: string;
} {
const cleanValue = value.trim().toLowerCase().replace(/\.$/, "");
if (!cleanValue) {
return {
valid: false,
error: "Bitte eine Domain eingeben."
};
}
if (cleanValue.length > 253) {
return {
valid: false,
error: "Die Domain ist zu lang."
};
}
if (
cleanValue.includes("://") ||
cleanValue.includes("/") ||
cleanValue.includes("?") ||
cleanValue.includes("#") ||
cleanValue.includes("@") ||
cleanValue.includes(":") ||
/\s/.test(cleanValue)
) {
return {
valid: false,
error: "Bitte nur die Domain ohne https://, Pfad oder Sonderzeichen eingeben."
};
}
const asciiDomain = domainToASCII(cleanValue);
if (!asciiDomain) {
return {
valid: false,
error: "Die Domain ist ungültig."
};
}
const labels = asciiDomain.split(".");
if (labels.length < 2) {
return {
valid: false,
error: "Bitte eine vollständige Domain inklusive TLD eingeben, z. B. example.com."
};
}
for (const label of labels) {
if (!label || label.length > 63) {
return {
valid: false,
error: "Ein Domain-Teil ist ungültig."
};
}
if (label.startsWith("-") || label.endsWith("-")) {
return {
valid: false,
error: "Domain-Teile dürfen nicht mit Bindestrich beginnen oder enden."
};
}
if (!/^[a-z0-9-]+$/.test(label)) {
return {
valid: false,
error: "Die Domain enthält ungültige Zeichen."
};
}
}
const tld = labels[labels.length - 1];
if (!tld || /^\d+$/.test(tld)) {
return {
valid: false,
error: "Die TLD ist ungültig."
};
}
return {
valid: true,
domain: cleanValue,
asciiDomain,
tld
};
}
async function fetchRdapBootstrap(): Promise<RdapBootstrap> {
const now = Date.now();
if (cachedBootstrap && cachedBootstrap.expiresAt > now) {
return cachedBootstrap.data;
}
const response = await fetch("https://data.iana.org/rdap/dns.json", {
headers: {
Accept: "application/json"
},
cache: "no-store"
});
if (!response.ok) {
throw new Error("RDAP-Verzeichnis konnte nicht geladen werden.");
}
const data = (await response.json()) as RdapBootstrap;
cachedBootstrap = {
data,
expiresAt: now + 24 * 60 * 60 * 1000
};
return data;
}
function findRdapUrls(bootstrap: RdapBootstrap, tld: string): string[] {
const normalizedTld = tld.toLowerCase();
for (const service of bootstrap.services) {
const tlds = service[0].map((item) => item.toLowerCase());
if (tlds.includes(normalizedTld)) {
return service[1];
}
}
return [];
}
function buildRdapDomainUrl(baseUrl: string, asciiDomain: string): string {
const cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${cleanBaseUrl}domain/${encodeURIComponent(asciiDomain)}`;
}
function extractRegistrarName(data: unknown): string | null {
if (!data || typeof data !== "object" || !("entities" in data)) {
return null;
}
const entities = (data as { entities?: unknown }).entities;
if (!Array.isArray(entities)) {
return null;
}
for (const entity of entities) {
if (!entity || typeof entity !== "object") {
continue;
}
const roles = (entity as { roles?: unknown }).roles;
if (!Array.isArray(roles) || !roles.includes("registrar")) {
continue;
}
const vcardArray = (entity as { vcardArray?: unknown }).vcardArray;
if (!Array.isArray(vcardArray) || !Array.isArray(vcardArray[1])) {
continue;
}
for (const field of vcardArray[1]) {
if (!Array.isArray(field) || field[0] !== "fn") {
continue;
}
if (typeof field[3] === "string" && field[3].trim()) {
return field[3].trim();
}
}
}
return null;
}
function extractNameservers(data: unknown): string[] {
if (!data || typeof data !== "object" || !("nameservers" in data)) {
return [];
}
const nameservers = (data as { nameservers?: unknown }).nameservers;
if (!Array.isArray(nameservers)) {
return [];
}
return nameservers
.map((nameserver) => {
if (!nameserver || typeof nameserver !== "object") {
return null;
}
const ldhName = (nameserver as { ldhName?: unknown }).ldhName;
return typeof ldhName === "string" ? ldhName : null;
})
.filter((item): item is string => Boolean(item))
.slice(0, 4);
}
function queryWhois(server: string, query: string): Promise<string> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(43, server);
let response = "";
let settled = false;
const finish = (error?: Error) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
if (error) {
reject(error);
} else {
resolve(response);
}
};
socket.setTimeout(whoisTimeoutMs);
socket.on("connect", () => {
socket.write(`${query}\r\n`);
});
socket.on("data", (chunk) => {
response += chunk.toString("utf8");
});
socket.on("end", () => finish());
socket.on("timeout", () => finish(new Error("WHOIS-Abfrage hat zu lange gedauert.")));
socket.on("error", (error) => finish(error));
});
}
function parseWhoisServerFromIana(response: string): string | null {
const match = response.match(/(?:refer|whois):\s*([^\s]+)/i);
if (!match?.[1]) {
return null;
}
return match[1].trim().toLowerCase();
}
async function findWhoisServer(tld: string): Promise<string | null> {
const ianaResponse = await queryWhois("whois.iana.org", tld);
const referredServer = parseWhoisServerFromIana(ianaResponse);
if (referredServer) {
return referredServer;
}
const fallbackServers: Record<string, string> = {
de: "whois.denic.de",
com: "whois.verisign-grs.com",
net: "whois.verisign-grs.com",
org: "whois.pir.org",
info: "whois.afilias.net",
biz: "whois.biz",
eu: "whois.eu",
at: "whois.nic.at",
ch: "whois.nic.ch",
li: "whois.nic.ch",
io: "whois.nic.io",
app: "whois.nic.google",
dev: "whois.nic.google"
};
return fallbackServers[tld.toLowerCase()] ?? null;
}
function parseWhoisAvailability(response: string, asciiDomain: string): {
status: Exclude<DomainCheckStatus, "invalid">;
message: string;
registrar: string | null;
nameservers: string[];
} {
const lower = response.toLowerCase();
const domainLower = asciiDomain.toLowerCase();
const availablePatterns = [
/status:\s*free/i,
/domain\s+not\s+found/i,
/no\s+match\s+for/i,
/not\s+found/i,
/no\s+data\s+found/i,
/no\s+entries\s+found/i,
/object\s+does\s+not\s+exist/i,
/available/i
];
const registeredPatterns = [
new RegExp(`domain:\\s*${domainLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"),
/status:\s*connect/i,
/domain status:/i,
/registrar:/i,
/registrar\s+name:/i,
/creation date:/i,
/created:/i,
/updated date:/i,
/name server:/i,
/nameserver:/i,
/nserver:/i
];
if (availablePatterns.some((pattern) => pattern.test(response))) {
return {
status: "available",
message: "Die Domain ist frei.",
registrar: null,
nameservers: []
};
}
if (registeredPatterns.some((pattern) => pattern.test(response)) || lower.includes(domainLower)) {
const registrar =
response.match(/registrar:\s*(.+)/i)?.[1]?.trim() ??
response.match(/registrar name:\s*(.+)/i)?.[1]?.trim() ??
null;
const nameservers = Array.from(
new Set(
response
.split("\n")
.map((line) => {
const match = line.match(/(?:name server|nameserver|nserver):\s*(.+)/i);
return match?.[1]?.trim().split(/\s+/)[0]?.replace(/\.$/, "") ?? null;
})
.filter((item): item is string => Boolean(item))
)
).slice(0, 4);
return {
status: "registered",
message: "Die Domain ist bereits registriert.",
registrar,
nameservers
};
}
return {
status: "unknown",
message: "Die WHOIS-Antwort konnte nicht eindeutig interpretiert werden.",
registrar: null,
nameservers: []
};
}
async function checkDomainWithWhois(asciiDomain: string, tld: string) {
const whoisServer = await findWhoisServer(tld);
if (!whoisServer) {
return {
status: "unknown" as const,
message: "Für diese TLD wurde kein WHOIS-Server gefunden.",
registrar: null,
nameservers: [],
whoisServer: null
};
}
const response = await queryWhois(whoisServer, asciiDomain);
const parsed = parseWhoisAvailability(response, asciiDomain);
return {
...parsed,
whoisServer
};
}
async function checkDomainWithRdap(asciiDomain: string, tld: string) {
const bootstrap = await fetchRdapBootstrap();
const rdapUrls = findRdapUrls(bootstrap, tld);
if (rdapUrls.length === 0) {
return null;
}
let lastError: string | null = null;
for (const rdapBaseUrl of rdapUrls) {
const rdapUrl = buildRdapDomainUrl(rdapBaseUrl, asciiDomain);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(rdapUrl, {
headers: {
Accept: "application/rdap+json, application/json"
},
signal: controller.signal,
cache: "no-store"
});
clearTimeout(timeout);
if (response.status === 404) {
return {
status: "available" as const,
message: "Die Domain ist frei.",
registrar: null,
nameservers: [],
rdapUrl
};
}
const data = (await response.json().catch(() => null)) as unknown;
if (response.ok) {
return {
status: "registered" as const,
message: "Die Domain ist bereits registriert.",
registrar: extractRegistrarName(data),
nameservers: extractNameservers(data),
rdapUrl
};
}
lastError = `RDAP antwortete mit Status ${response.status}.`;
} catch (error) {
lastError = error instanceof Error ? error.message : "RDAP-Abfrage fehlgeschlagen.";
}
}
return {
status: "unknown" as const,
message: lastError ?? "RDAP konnte die Domain nicht eindeutig prüfen.",
registrar: null,
nameservers: [],
rdapUrl: null
};
}
export async function GET(request: NextRequest) {
try {
await requireCurrentUser();
const rawDomain = request.nextUrl.searchParams.get("domain") ?? "";
const normalizedDomain = normalizeDomain(rawDomain);
if (!normalizedDomain.valid || !normalizedDomain.asciiDomain || !normalizedDomain.domain || !normalizedDomain.tld) {
return NextResponse.json(
{
status: "invalid",
domain: rawDomain,
message: normalizedDomain.error ?? "Domain ist ungültig."
},
{ status: 400 }
);
}
const rdapResult = await checkDomainWithRdap(normalizedDomain.asciiDomain, normalizedDomain.tld).catch(() => null);
if (rdapResult && rdapResult.status !== "unknown") {
return NextResponse.json({
status: rdapResult.status,
domain: normalizedDomain.domain,
asciiDomain: normalizedDomain.asciiDomain,
message: rdapResult.message,
registrar: rdapResult.registrar,
nameservers: rdapResult.nameservers,
rdapUrl: rdapResult.rdapUrl,
source: "RDAP",
checkedAt: new Date().toISOString()
});
}
const whoisResult = await checkDomainWithWhois(normalizedDomain.asciiDomain, normalizedDomain.tld);
return NextResponse.json({
status: whoisResult.status,
domain: normalizedDomain.domain,
asciiDomain: normalizedDomain.asciiDomain,
message:
whoisResult.status === "unknown" && rdapResult?.message
? `${whoisResult.message} RDAP: ${rdapResult.message}`
: whoisResult.message,
registrar: whoisResult.registrar,
nameservers: whoisResult.nameservers,
whoisServer: whoisResult.whoisServer,
source: "WHOIS",
checkedAt: new Date().toISOString()
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{
status: "unknown",
message: error instanceof Error ? error.message : "Domainprüfung fehlgeschlagen."
},
{ status: 500 }
);
}
}
+184
View File
@@ -0,0 +1,184 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
function normalizeTitle(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, 120);
}
function normalizeUrl(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
try {
const parsedUrl = new URL(cleanValue);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
return parsedUrl.toString().slice(0, 1000);
} catch {
return null;
}
}
function normalizeOptionalUrl(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
try {
const parsedUrl = new URL(cleanValue);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
return parsedUrl.toString().slice(0, 1000);
} catch {
return null;
}
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const body = (await request.json().catch(() => null)) as {
title?: unknown;
url?: unknown;
iconUrl?: unknown;
position?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const favorite = await prisma.favorite.findFirst({
where: {
id: params.id,
userId: user.id
}
});
if (!favorite) {
return NextResponse.json({ error: "Favorit nicht gefunden." }, { status: 404 });
}
const data: {
title?: string;
url?: string;
iconUrl?: string | null;
position?: number;
} = {};
if (Object.prototype.hasOwnProperty.call(body, "title")) {
const title = normalizeTitle(body.title);
if (!title) {
return NextResponse.json({ error: "Titel ist ungültig." }, { status: 400 });
}
data.title = title;
}
if (Object.prototype.hasOwnProperty.call(body, "url")) {
const url = normalizeUrl(body.url);
if (!url) {
return NextResponse.json({ error: "URL ist ungültig." }, { status: 400 });
}
data.url = url;
}
if (Object.prototype.hasOwnProperty.call(body, "iconUrl")) {
data.iconUrl = normalizeOptionalUrl(body.iconUrl);
}
if (Object.prototype.hasOwnProperty.call(body, "position")) {
const position = Number(body.position);
if (Number.isFinite(position)) {
data.position = Math.max(0, Math.min(10000, Math.round(position)));
}
}
const updatedFavorite = await prisma.favorite.update({
where: {
id: favorite.id
},
data
});
return NextResponse.json({
favorite: updatedFavorite
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Favorit konnte nicht gespeichert werden." }, { status: 500 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
await prisma.favorite.deleteMany({
where: {
id: params.id,
userId: user.id
}
});
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Favorit konnte nicht gelöscht werden." }, { status: 500 });
}
}
+413
View File
@@ -0,0 +1,413 @@
import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function normalizeTitle(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, 120);
}
function normalizeUrl(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
try {
const parsedUrl = new URL(cleanValue);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
return parsedUrl.toString().slice(0, 1000);
} catch {
return null;
}
}
function normalizeOptionalImageUrl(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
if (cleanValue.startsWith("/api/uploads/") || cleanValue.startsWith("/uploads/")) {
return cleanValue.slice(0, 1000);
}
try {
const parsedUrl = new URL(cleanValue);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
return parsedUrl.toString().slice(0, 1000);
} catch {
return null;
}
}
function buildDefaultIconUrl(url: string): string | null {
try {
const parsedUrl = new URL(url);
return `${parsedUrl.origin}/favicon.ico`;
} catch {
return null;
}
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">");
}
function getHtmlAttribute(tag: string, attributeName: string): string | null {
const pattern = new RegExp(`${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>` + "`" + `]+))`, "i");
const match = tag.match(pattern);
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
return value ? decodeHtmlEntities(value.trim()) : null;
}
function isIconRel(rel: string): boolean {
const relParts = rel
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
return (
relParts.includes("icon") ||
relParts.includes("shortcut") ||
relParts.includes("apple-touch-icon") ||
relParts.includes("apple-touch-icon-precomposed") ||
relParts.includes("mask-icon")
);
}
function scoreIconTag(tag: string): number {
const rel = getHtmlAttribute(tag, "rel")?.toLowerCase() ?? "";
const href = getHtmlAttribute(tag, "href")?.toLowerCase() ?? "";
const sizes = getHtmlAttribute(tag, "sizes")?.toLowerCase() ?? "";
let score = 0;
if (rel.includes("apple-touch-icon")) {
score += 60;
}
if (rel.includes("icon")) {
score += 50;
}
if (rel.includes("shortcut")) {
score += 40;
}
if (href.endsWith(".svg")) {
score += 30;
}
if (href.endsWith(".png")) {
score += 20;
}
if (href.endsWith(".ico")) {
score += 10;
}
const sizeMatch = sizes.match(/(\d+)x(\d+)/);
if (sizeMatch) {
score += Math.min(50, Number(sizeMatch[1]) / 4);
}
return score;
}
async function discoverIconUrl(pageUrl: string): Promise<string | null> {
const fallbackIconUrl = buildDefaultIconUrl(pageUrl);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(pageUrl, {
method: "GET",
redirect: "follow",
signal: controller.signal,
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "personal-dashboard-favicon-fetcher/1.0"
}
}).finally(() => clearTimeout(timeout));
if (!response.ok) {
return fallbackIconUrl;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("html")) {
return fallbackIconUrl;
}
const html = (await response.text()).slice(0, 250000);
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const iconCandidates = linkTags
.map((tag) => {
const rel = getHtmlAttribute(tag, "rel");
const href = getHtmlAttribute(tag, "href");
if (!rel || !href || !isIconRel(rel)) {
return null;
}
try {
const absoluteUrl = new URL(href, response.url || pageUrl);
if (absoluteUrl.protocol !== "http:" && absoluteUrl.protocol !== "https:") {
return null;
}
return {
url: absoluteUrl.toString().slice(0, 1000),
score: scoreIconTag(tag)
};
} catch {
return null;
}
})
.filter((candidate): candidate is { url: string; score: number } => Boolean(candidate))
.sort((a, b) => b.score - a.score);
return iconCandidates[0]?.url ?? fallbackIconUrl;
} catch {
return fallbackIconUrl;
}
}
async function getFirstFavoritesWidgetId(userId: string): Promise<string | null> {
const widget = await prisma.widget.findFirst({
where: {
userId,
type: "favorites"
},
orderBy: [
{
y: "asc"
},
{
x: "asc"
},
{
createdAt: "asc"
}
],
select: {
id: true
}
});
return widget?.id ?? null;
}
async function requireFavoritesWidget(userId: string, widgetId: string) {
const widget = await prisma.widget.findFirst({
where: {
id: widgetId,
userId,
type: "favorites"
},
select: {
id: true
}
});
if (!widget) {
throw new Error("Links/Favoriten-Widget nicht gefunden.");
}
return widget;
}
async function backfillLegacyFavorites(userId: string) {
const firstWidgetId = await getFirstFavoritesWidgetId(userId);
if (!firstWidgetId) {
return;
}
await prisma.favorite.updateMany({
where: {
userId,
widgetId: null
},
data: {
widgetId: firstWidgetId
}
});
}
function logRouteError(context: string, error: unknown) {
console.error(
`[${new Date().toISOString()}] ${context}`,
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack
}
: error
);
}
export async function GET(request: NextRequest) {
try {
const user = await requireCurrentUser();
await backfillLegacyFavorites(user.id);
const widgetId = request.nextUrl.searchParams.get("widgetId")?.trim() || null;
if (widgetId) {
await requireFavoritesWidget(user.id, widgetId);
}
const favorites = await prisma.favorite.findMany({
where: {
userId: user.id,
...(widgetId ? { widgetId } : {})
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
return NextResponse.json({
favorites
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
logRouteError("GET /api/favorites failed", error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Favoriten konnten nicht geladen werden."
},
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
await backfillLegacyFavorites(user.id);
const body = (await request.json().catch(() => null)) as {
widgetId?: unknown;
title?: unknown;
url?: unknown;
iconUrl?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const widgetId = typeof body.widgetId === "string" ? body.widgetId.trim() : "";
const title = normalizeTitle(body.title);
const url = normalizeUrl(body.url);
const explicitIconUrl = normalizeOptionalImageUrl(body.iconUrl);
if (!widgetId) {
return NextResponse.json({ error: "widgetId fehlt." }, { status: 400 });
}
await requireFavoritesWidget(user.id, widgetId);
if (!title || !url) {
return NextResponse.json({ error: "Titel oder URL ist ungültig." }, { status: 400 });
}
const iconUrl = explicitIconUrl ?? (await discoverIconUrl(url));
const lastFavorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
widgetId
},
orderBy: {
position: "desc"
},
select: {
position: true
}
});
const favorite = await prisma.favorite.create({
data: {
userId: user.id,
widgetId,
title,
url,
iconUrl,
position: (lastFavorite?.position ?? -1) + 1
}
});
return NextResponse.json(
{
favorite
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
logRouteError("POST /api/favorites failed", error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Favorit konnte nicht erstellt werden."
},
{ status: 500 }
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizePositiveInteger, normalizeTitle } from "@/lib/validation";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
type NoteType = "note";
function normalizeNoteType(_value: unknown, _fallback: string): NoteType {
return "note";
}
function normalizeContent(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
return value.slice(0, 10000);
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const body = (await request.json().catch(() => null)) as {
type?: unknown;
title?: unknown;
content?: unknown;
x?: unknown;
y?: unknown;
w?: unknown;
h?: unknown;
} | null;
const currentNote = await prisma.noteBoardItem.findFirst({
where: {
id: params.id,
userId: user.id
}
});
if (!currentNote) {
return NextResponse.json({ error: "Notiz nicht gefunden." }, { status: 404 });
}
const hasTitle = body && Object.prototype.hasOwnProperty.call(body, "title");
const title = hasTitle ? normalizeTitle(body?.title) : currentNote.title;
if (!title) {
return NextResponse.json({ error: "Notiz-Titel ist ungültig." }, { status: 400 });
}
const note = await prisma.noteBoardItem.update({
where: {
id: currentNote.id
},
data: {
type:
body && Object.prototype.hasOwnProperty.call(body, "type")
? normalizeNoteType(body.type, currentNote.type)
: currentNote.type,
title,
content:
body && Object.prototype.hasOwnProperty.call(body, "content")
? normalizeContent(body.content, currentNote.content)
: currentNote.content,
x: normalizePositiveInteger(body?.x, currentNote.x, 0, 10000),
y: normalizePositiveInteger(body?.y, currentNote.y, 0, 10000),
w: normalizePositiveInteger(body?.w, currentNote.w, 1, 12),
h: normalizePositiveInteger(body?.h, currentNote.h, 1, 30)
}
});
return NextResponse.json({
note
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Notiz konnte nicht gespeichert werden." }, { status: 500 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const deleteResult = await prisma.noteBoardItem.deleteMany({
where: {
id: params.id,
userId: user.id
}
});
if (deleteResult.count === 0) {
return NextResponse.json({ error: "Notiz nicht gefunden." }, { status: 404 });
}
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Notiz konnte nicht gelöscht werden." }, { status: 500 });
}
}
+179
View File
@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizePositiveInteger, normalizeTitle } from "@/lib/validation";
type NoteType = "note";
function normalizeNoteType(_value: unknown): NoteType {
return "note";
}
function normalizeContent(value: unknown): string {
if (typeof value !== "string") {
return "";
}
return value.slice(0, 10000);
}
function normalizeOptionalId(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue || cleanValue.length > 200) {
return null;
}
return cleanValue;
}
async function ensureNotesForWidgets(userId: string) {
const noteWidgets = await prisma.widget.findMany({
where: {
userId,
type: {
in: ["note"]
}
},
select: {
id: true,
type: true,
title: true
}
});
if (noteWidgets.length === 0) {
return;
}
const existingNotes = await prisma.noteBoardItem.findMany({
where: {
userId,
id: {
in: noteWidgets.map((widget) => widget.id)
}
},
select: {
id: true
}
});
const existingNoteIds = new Set(existingNotes.map((note) => note.id));
const missingWidgets = noteWidgets.filter((widget) => !existingNoteIds.has(widget.id));
if (missingWidgets.length === 0) {
return;
}
await prisma.$transaction(
missingWidgets.map((widget) =>
prisma.noteBoardItem.create({
data: {
id: widget.id,
userId,
type: "note",
title: widget.title?.trim() || "Notiz",
content: ""
}
})
)
);
}
export async function GET() {
try {
const user = await requireCurrentUser();
await ensureNotesForWidgets(user.id);
const notes = await prisma.noteBoardItem.findMany({
where: {
userId: user.id
},
orderBy: [
{
createdAt: "asc"
}
]
});
return NextResponse.json({
notes
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Notizen konnten nicht geladen werden." }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
const body = (await request.json().catch(() => null)) as {
id?: unknown;
type?: unknown;
title?: unknown;
content?: unknown;
x?: unknown;
y?: unknown;
w?: unknown;
h?: unknown;
} | null;
const id = normalizeOptionalId(body?.id);
const type = normalizeNoteType(body?.type);
const fallbackTitle = "Notiz";
const title = normalizeTitle(body?.title) ?? fallbackTitle;
const content = normalizeContent(body?.content);
if (id) {
const existingNote = await prisma.noteBoardItem.findFirst({
where: {
id,
userId: user.id
}
});
if (existingNote) {
return NextResponse.json({
note: existingNote
});
}
}
const note = await prisma.noteBoardItem.create({
data: {
...(id ? { id } : {}),
userId: user.id,
type,
title,
content,
x: normalizePositiveInteger(body?.x, 0, 0, 10000),
y: normalizePositiveInteger(body?.y, 0, 0, 10000),
w: normalizePositiveInteger(body?.w, 3, 1, 12),
h: normalizePositiveInteger(body?.h, 3, 1, 30)
}
});
return NextResponse.json(
{
note
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Notiz konnte nicht erstellt werden." }, { status: 500 });
}
}
+107
View File
@@ -0,0 +1,107 @@
import { mkdir, stat, writeFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const uploadRoot = "/data/uploads";
const maxFileSizeBytes = 10 * 1024 * 1024;
const allowedMimeTypes: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif"
};
function getExtension(file: File): string | null {
return allowedMimeTypes[file.type] ?? null;
}
async function ensureSettings(userId: string) {
const existingSettings = await prisma.settings.findUnique({
where: {
userId
}
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
logoUrl: "/logo.svg",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe"
}
});
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
await ensureSettings(user.id);
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Keine Datei hochgeladen." }, { status: 400 });
}
if (file.size <= 0 || file.size > maxFileSizeBytes) {
return NextResponse.json({ error: "Datei ist leer oder größer als 10 MB." }, { status: 400 });
}
const extension = getExtension(file);
if (!extension) {
return NextResponse.json({ error: "Nur PNG, JPG, WEBP oder GIF sind erlaubt." }, { status: 400 });
}
const userUploadDirectory = path.join(uploadRoot, "users", user.id);
await mkdir(userUploadDirectory, { recursive: true });
const fileName = `background-${Date.now()}-${crypto.randomUUID()}.${extension}`;
const filePath = path.join(userUploadDirectory, fileName);
const bytes = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(bytes));
const writtenFile = await stat(filePath);
if (!writtenFile.isFile() || writtenFile.size <= 0) {
return NextResponse.json({ error: "Hintergrund wurde nicht korrekt gespeichert." }, { status: 500 });
}
const backgroundImageUrl = `/api/uploads/users/${user.id}/${fileName}`;
const settings = await prisma.settings.update({
where: {
userId: user.id
},
data: {
backgroundImageUrl,
backgroundImageOpacity: 35
}
});
return NextResponse.json({
settings
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Hintergrund konnte nicht hochgeladen werden." }, { status: 500 });
}
}
+123
View File
@@ -0,0 +1,123 @@
import { mkdir, stat, writeFile } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const uploadRoot = "/data/uploads";
const maxFileSizeBytes = 2 * 1024 * 1024;
const allowedMimeTypes: Record<string, string> = {
"image/png": "png",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"image/vnd.microsoft.icon": "ico"
};
function getExtension(file: File): string | null {
return allowedMimeTypes[file.type] ?? null;
}
async function ensureSettings(userId: string, email: string) {
const existingSettings = await prisma.settings.findUnique({
where: { userId }
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarIcsUrl: null,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
dashboardSubtitle: email,
logoUrl: "/logo.svg",
faviconUrl: "/favicon.ico",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe",
customCss: ""
}
});
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
await ensureSettings(user.id, user.email);
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Keine Datei hochgeladen." }, { status: 400 });
}
if (file.size <= 0 || file.size > maxFileSizeBytes) {
return NextResponse.json({ error: "Datei ist leer oder größer als 2 MB." }, { status: 400 });
}
const extension = getExtension(file);
if (!extension) {
return NextResponse.json({ error: "Nur ICO, PNG oder SVG sind erlaubt." }, { status: 400 });
}
const userUploadDirectory = path.join(uploadRoot, "users", user.id);
await mkdir(userUploadDirectory, { recursive: true });
const fileName = `favicon-${Date.now()}-${randomUUID()}.${extension}`;
const filePath = path.join(userUploadDirectory, fileName);
const bytes = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(bytes));
const writtenFile = await stat(filePath);
if (!writtenFile.isFile() || writtenFile.size <= 0) {
return NextResponse.json({ error: "Favicon wurde nicht korrekt gespeichert." }, { status: 500 });
}
const faviconUrl = `/api/uploads/users/${user.id}/${fileName}`;
const settings = await prisma.settings.update({
where: { userId: user.id },
data: { faviconUrl }
});
return NextResponse.json({ settings });
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Favicon konnte nicht hochgeladen werden." }, { status: 500 });
}
}
export async function DELETE() {
try {
const user = await requireCurrentUser();
await ensureSettings(user.id, user.email);
const settings = await prisma.settings.update({
where: { userId: user.id },
data: { faviconUrl: "/favicon.ico" }
});
return NextResponse.json({ settings });
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Favicon konnte nicht zurückgesetzt werden." }, { status: 500 });
}
}
+122
View File
@@ -0,0 +1,122 @@
import { mkdir, stat, writeFile } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const uploadRoot = "/data/uploads";
const maxFileSizeBytes = 5 * 1024 * 1024;
const allowedMimeTypes: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"image/svg+xml": "svg"
};
function getExtension(file: File): string | null {
return allowedMimeTypes[file.type] ?? null;
}
async function ensureSettings(userId: string, email: string) {
const existingSettings = await prisma.settings.findUnique({
where: { userId }
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarIcsUrl: null,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
dashboardSubtitle: email,
logoUrl: "/logo.svg",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe"
}
});
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
await ensureSettings(user.id, user.email);
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Keine Datei hochgeladen." }, { status: 400 });
}
if (file.size <= 0 || file.size > maxFileSizeBytes) {
return NextResponse.json({ error: "Datei ist leer oder größer als 5 MB." }, { status: 400 });
}
const extension = getExtension(file);
if (!extension) {
return NextResponse.json({ error: "Nur PNG, JPG, WEBP, GIF oder SVG sind erlaubt." }, { status: 400 });
}
const userUploadDirectory = path.join(uploadRoot, "users", user.id);
await mkdir(userUploadDirectory, { recursive: true });
const fileName = `logo-${Date.now()}-${randomUUID()}.${extension}`;
const filePath = path.join(userUploadDirectory, fileName);
const bytes = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(bytes));
const writtenFile = await stat(filePath);
if (!writtenFile.isFile() || writtenFile.size <= 0) {
return NextResponse.json({ error: "Logo wurde nicht korrekt gespeichert." }, { status: 500 });
}
const logoUrl = `/api/uploads/users/${user.id}/${fileName}`;
const settings = await prisma.settings.update({
where: { userId: user.id },
data: { logoUrl }
});
return NextResponse.json({ settings });
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Logo konnte nicht hochgeladen werden." }, { status: 500 });
}
}
export async function DELETE() {
try {
const user = await requireCurrentUser();
await ensureSettings(user.id, user.email);
const settings = await prisma.settings.update({
where: { userId: user.id },
data: { logoUrl: "/logo.svg" }
});
return NextResponse.json({ settings });
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Logo konnte nicht zurückgesetzt werden." }, { status: 500 });
}
}
+265
View File
@@ -0,0 +1,265 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") {
return value;
}
return fallback;
}
function normalizeOptionalText(value: unknown, maxLength: number): string | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value !== "string") {
return undefined;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, maxLength);
}
function normalizeCustomCss(value: unknown, fallback: string): string {
if (value === undefined) {
return fallback;
}
if (value === null) {
return "";
}
if (typeof value !== "string") {
return fallback;
}
return value.slice(0, 20000);
}
function normalizeTitle(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const cleanValue = value.trim();
if (!cleanValue) {
return undefined;
}
return cleanValue.slice(0, 120);
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number): number {
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.round(numberValue)));
}
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const cleanValue = value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(cleanValue)) {
return cleanValue;
}
return fallback;
}
function normalizeUrl(value: unknown): string | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value !== "string") {
return undefined;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
if (
cleanValue.startsWith("/uploads/") ||
cleanValue.startsWith("/api/uploads/") ||
cleanValue === "/logo.svg" ||
cleanValue === "/favicon.ico" ||
cleanValue === "/background-fancy.svg"
) {
return cleanValue.slice(0, 1000);
}
try {
const parsedUrl = new URL(cleanValue);
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
return parsedUrl.toString().slice(0, 1000);
}
return undefined;
} catch {
return undefined;
}
}
async function ensureSettings(userId: string, email: string) {
const existingSettings = await prisma.settings.findUnique({
where: {
userId
}
});
if (existingSettings) {
return existingSettings;
}
return prisma.settings.create({
data: {
userId,
darkMode: false,
calendarIcsUrl: null,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
dashboardSubtitle: email,
logoUrl: "/logo.svg",
faviconUrl: "/favicon.ico",
backgroundImageUrl: "/background-fancy.svg",
backgroundImageOpacity: 32,
primaryColor: "#2563eb",
secondaryColor: "#dbeafe",
customCss: ""
}
});
}
export async function GET() {
try {
const user = await requireCurrentUser();
const settings = await ensureSettings(user.id, user.email);
return NextResponse.json({
settings
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Einstellungen konnten nicht geladen werden." }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await requireCurrentUser();
const currentSettings = await ensureSettings(user.id, user.email);
const body = (await request.json().catch(() => null)) as {
darkMode?: unknown;
calendarIcsUrl?: unknown;
calendarMaxEvents?: unknown;
calendarLookaheadDays?: unknown;
dashboardTitle?: unknown;
dashboardSubtitle?: unknown;
logoUrl?: unknown;
faviconUrl?: unknown;
backgroundImageUrl?: unknown;
backgroundImageOpacity?: unknown;
primaryColor?: unknown;
secondaryColor?: unknown;
customCss?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const normalizedCalendarIcsUrl = normalizeOptionalText(body.calendarIcsUrl, 1000);
const normalizedDashboardSubtitle = normalizeOptionalText(body.dashboardSubtitle, 160);
const logoUrl = normalizeUrl(body.logoUrl);
const faviconUrl = normalizeUrl(body.faviconUrl);
const backgroundImageUrl = normalizeUrl(body.backgroundImageUrl);
if (body.logoUrl !== undefined && logoUrl === undefined) {
return NextResponse.json({ error: "Logo-URL ist ungültig." }, { status: 400 });
}
if (body.faviconUrl !== undefined && faviconUrl === undefined) {
return NextResponse.json({ error: "Favicon-URL ist ungültig." }, { status: 400 });
}
if (body.backgroundImageUrl !== undefined && backgroundImageUrl === undefined) {
return NextResponse.json({ error: "Hintergrund-URL ist ungültig." }, { status: 400 });
}
const settings = await prisma.settings.update({
where: {
userId: user.id
},
data: {
darkMode: normalizeBoolean(body.darkMode, currentSettings.darkMode),
calendarIcsUrl:
normalizedCalendarIcsUrl === undefined
? currentSettings.calendarIcsUrl
: normalizedCalendarIcsUrl,
calendarMaxEvents: normalizeInteger(body.calendarMaxEvents, currentSettings.calendarMaxEvents, 1, 50),
calendarLookaheadDays: normalizeInteger(body.calendarLookaheadDays, currentSettings.calendarLookaheadDays, 1, 365),
dashboardTitle: normalizeTitle(body.dashboardTitle) ?? currentSettings.dashboardTitle,
dashboardSubtitle:
normalizedDashboardSubtitle === undefined
? currentSettings.dashboardSubtitle
: normalizedDashboardSubtitle,
logoUrl: logoUrl === undefined ? currentSettings.logoUrl : logoUrl,
faviconUrl: faviconUrl === undefined ? currentSettings.faviconUrl : faviconUrl,
backgroundImageUrl:
backgroundImageUrl === undefined ? currentSettings.backgroundImageUrl : backgroundImageUrl,
backgroundImageOpacity: normalizeInteger(
body.backgroundImageOpacity,
currentSettings.backgroundImageOpacity,
0,
100
),
primaryColor: normalizeColor(body.primaryColor, currentSettings.primaryColor),
secondaryColor: normalizeColor(body.secondaryColor, currentSettings.secondaryColor),
customCss: normalizeCustomCss(body.customCss, currentSettings.customCss ?? "")
}
});
return NextResponse.json({
settings
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Einstellungen konnten nicht gespeichert werden." }, { status: 500 });
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizePositiveInteger, normalizeTitle } from "@/lib/validation";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
async function normalizeTabPositions(userId: string) {
const tabs = await prisma.dashboardTab.findMany({
where: {
userId
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
await Promise.all(
tabs.map((tab, index) =>
prisma.dashboardTab.update({
where: {
id: tab.id
},
data: {
position: index
}
})
)
);
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const body = (await request.json().catch(() => null)) as {
title?: unknown;
position?: unknown;
} | null;
const existingTab = await prisma.dashboardTab.findFirst({
where: {
id: params.id,
userId: user.id
}
});
if (!existingTab) {
return NextResponse.json({ error: "Tab nicht gefunden." }, { status: 404 });
}
const title = body && Object.prototype.hasOwnProperty.call(body, "title")
? normalizeTitle(body.title)
: existingTab.title;
if (!title) {
return NextResponse.json({ error: "Tab-Titel ist ungültig." }, { status: 400 });
}
const tab = await prisma.dashboardTab.update({
where: {
id: existingTab.id
},
data: {
title,
position: normalizePositiveInteger(body?.position, existingTab.position, 0, 10000)
}
});
return NextResponse.json({
tab
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Tab konnte nicht gespeichert werden." }, { status: 500 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const tabs = await prisma.dashboardTab.findMany({
where: {
userId: user.id
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
if (tabs.length <= 1) {
return NextResponse.json({ error: "Der letzte Tab kann nicht gelöscht werden." }, { status: 400 });
}
const existingTab = tabs.find((tab) => tab.id === params.id);
if (!existingTab) {
return NextResponse.json({ error: "Tab nicht gefunden." }, { status: 404 });
}
const widgets = await prisma.widget.findMany({
where: {
userId: user.id,
tabId: existingTab.id
},
select: {
id: true
}
});
const widgetIds = widgets.map((widget) => widget.id);
await prisma.$transaction([
prisma.noteBoardItem.deleteMany({
where: {
userId: user.id,
id: {
in: widgetIds
}
}
}),
prisma.widget.deleteMany({
where: {
userId: user.id,
tabId: existingTab.id
}
}),
prisma.dashboardTab.delete({
where: {
id: existingTab.id
}
})
]);
await normalizeTabPositions(user.id);
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Tab konnte nicht gelöscht werden." }, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
async function ensureDefaultTab(userId: string) {
const existingTab = await prisma.dashboardTab.findFirst({
where: { userId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }]
});
if (existingTab) {
await prisma.widget.updateMany({
where: {
userId,
tabId: null
},
data: {
tabId: existingTab.id
}
});
return existingTab;
}
const tab = await prisma.dashboardTab.create({
data: {
userId,
title: "Dashboard",
position: 0
}
});
await prisma.widget.updateMany({
where: {
userId,
tabId: null
},
data: {
tabId: tab.id
}
});
return tab;
}
export async function GET() {
try {
const user = await requireCurrentUser();
await ensureDefaultTab(user.id);
const tabs = await prisma.dashboardTab.findMany({
where: {
userId: user.id
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
return NextResponse.json({
tabs
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Tabs konnten nicht geladen werden." }, { status: 500 });
}
}
export async function POST() {
try {
const user = await requireCurrentUser();
await ensureDefaultTab(user.id);
const lastTab = await prisma.dashboardTab.findFirst({
where: {
userId: user.id
},
orderBy: {
position: "desc"
},
select: {
position: true
}
});
const position = (lastTab?.position ?? -1) + 1;
const tab = await prisma.dashboardTab.create({
data: {
userId: user.id,
title: `Dashboard ${position + 1}`,
position
}
});
return NextResponse.json(
{
tab
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Tab konnte nicht erstellt werden." }, { status: 500 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { readFile, stat } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type RouteContext = {
params: Promise<{
path: string[];
}>;
};
const uploadRoot = "/data/uploads";
const contentTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml"
};
function resolveSafeUploadPath(parts: string[]): string | null {
if (!Array.isArray(parts) || parts.length === 0) {
return null;
}
const requestedPath = parts.join("/");
const normalizedRelativePath = path.normalize(requestedPath).replace(/^(\.\.(\/|\\|$))+/, "");
const resolvedUploadRoot = path.resolve(uploadRoot);
const resolvedAbsolutePath = path.resolve(path.join(uploadRoot, normalizedRelativePath));
if (resolvedAbsolutePath !== resolvedUploadRoot && !resolvedAbsolutePath.startsWith(resolvedUploadRoot + path.sep)) {
return null;
}
return resolvedAbsolutePath;
}
export async function GET(_request: Request, context: RouteContext) {
const params = await context.params;
const absolutePath = resolveSafeUploadPath(params.path);
if (!absolutePath) {
return NextResponse.json({ error: "Ungültiger Pfad." }, { status: 400 });
}
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
return NextResponse.json({ error: "Datei nicht gefunden." }, { status: 404 });
}
const file = await readFile(absolutePath);
const extension = path.extname(absolutePath).toLowerCase();
const contentType = contentTypes[extension] ?? "application/octet-stream";
return new NextResponse(file, {
headers: {
"Content-Type": contentType,
"Content-Length": String(file.length),
"Cache-Control": "public, max-age=3600"
}
});
} catch {
return NextResponse.json({ error: "Datei nicht gefunden." }, { status: 404 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { readFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const LOGO_UPLOAD_DIR = process.env.LOGO_UPLOAD_DIR ?? "/data/uploads/logos";
type RouteContext = {
params: Promise<{
file: string;
}>;
};
function getContentType(filename: string): string | null {
const lowerFilename = filename.toLowerCase();
if (lowerFilename.endsWith(".png")) {
return "image/png";
}
if (lowerFilename.endsWith(".jpg") || lowerFilename.endsWith(".jpeg")) {
return "image/jpeg";
}
if (lowerFilename.endsWith(".webp")) {
return "image/webp";
}
return null;
}
export async function GET(_request: Request, context: RouteContext) {
const params = await context.params;
const filename = params.file;
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
return NextResponse.json({ error: "Ungültiger Dateiname." }, { status: 400 });
}
const contentType = getContentType(filename);
if (!contentType) {
return NextResponse.json({ error: "Ungültiger Dateityp." }, { status: 400 });
}
try {
const filePath = path.join(LOGO_UPLOAD_DIR, filename);
const file = await readFile(filePath);
return new NextResponse(file, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff"
}
});
} catch {
return NextResponse.json({ error: "Logo nicht gefunden." }, { status: 404 });
}
}
+176
View File
@@ -0,0 +1,176 @@
import { randomBytes } from "crypto";
import { mkdir, unlink, writeFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024;
const LOGO_UPLOAD_DIR = process.env.LOGO_UPLOAD_DIR ?? "/data/uploads/logos";
type ImageType = {
extension: "png" | "jpg" | "webp";
contentType: "image/png" | "image/jpeg" | "image/webp";
};
function detectImageType(buffer: Buffer): ImageType | null {
const isPng =
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a;
if (isPng) {
return {
extension: "png",
contentType: "image/png"
};
}
const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
if (isJpeg) {
return {
extension: "jpg",
contentType: "image/jpeg"
};
}
const isWebp =
buffer.length >= 12 &&
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
buffer.subarray(8, 12).toString("ascii") === "WEBP";
if (isWebp) {
return {
extension: "webp",
contentType: "image/webp"
};
}
return null;
}
function getUploadedLogoFilename(logoUrl: string | null): string | null {
if (!logoUrl) {
return null;
}
const prefix = "/api/uploads/logo/";
if (!logoUrl.startsWith(prefix)) {
return null;
}
const filename = logoUrl.slice(prefix.length);
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
return null;
}
return filename;
}
async function deleteOldUploadedLogo(logoUrl: string | null, newFilename: string): Promise<void> {
const oldFilename = getUploadedLogoFilename(logoUrl);
if (!oldFilename || oldFilename === newFilename) {
return;
}
const oldPath = path.join(LOGO_UPLOAD_DIR, oldFilename);
try {
await unlink(oldPath);
} catch {
// Altes Logo muss nicht existieren. Der Upload soll dadurch nicht fehlschlagen.
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
const formData = await request.formData();
const uploadedFile = formData.get("logo");
if (!(uploadedFile instanceof File)) {
return NextResponse.json({ error: "Keine Logo-Datei erhalten." }, { status: 400 });
}
if (uploadedFile.size <= 0) {
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
}
if (uploadedFile.size > MAX_LOGO_SIZE_BYTES) {
return NextResponse.json({ error: "Das Logo darf maximal 2 MB groß sein." }, { status: 400 });
}
const arrayBuffer = await uploadedFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const imageType = detectImageType(buffer);
if (!imageType) {
return NextResponse.json(
{
error: "Ungültiges Dateiformat. Erlaubt sind PNG, JPG und WebP."
},
{ status: 400 }
);
}
const existingSettings = await prisma.settings.upsert({
where: {
userId: user.id
},
create: {
userId: user.id,
dashboardTitle: "Personal Dashboard",
logoUrl: "/logo.svg"
},
update: {}
});
await mkdir(LOGO_UPLOAD_DIR, {
recursive: true
});
const filename = `${user.id}-${Date.now()}-${randomBytes(8).toString("hex")}.${imageType.extension}`;
const absolutePath = path.join(LOGO_UPLOAD_DIR, filename);
await writeFile(absolutePath, buffer, {
flag: "wx"
});
const logoUrl = `/api/uploads/logo/${filename}`;
const settings = await prisma.settings.update({
where: {
userId: user.id
},
data: {
logoUrl
}
});
await deleteOldUploadedLogo(existingSettings.logoUrl, filename);
return NextResponse.json({
settings
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Logo konnte nicht hochgeladen werden." }, { status: 500 });
}
}
+208
View File
@@ -0,0 +1,208 @@
import { rm } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
function normalizeEmail(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim().toLowerCase();
if (!cleanValue || cleanValue.length > 320) {
return null;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanValue)) {
return null;
}
return cleanValue;
}
function normalizeDisplayName(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== "string") {
return null;
}
const cleanValue = value.trim();
if (!cleanValue) {
return null;
}
return cleanValue.slice(0, 120);
}
async function removeUserUploads(userId: string) {
const uploadRoot = "/data/uploads/users";
const targetPath = path.resolve(uploadRoot, userId);
const normalizedUploadRoot = path.resolve(uploadRoot);
if (!targetPath.startsWith(normalizedUploadRoot + path.sep)) {
throw new Error("Ungültiger Upload-Pfad.");
}
await rm(targetPath, {
recursive: true,
force: true
});
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const currentUser = await requireCurrentUser();
if (currentUser.role !== "ADMIN") {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const params = await context.params;
const userId = params.id.trim();
if (!userId) {
return NextResponse.json({ error: "Benutzer-ID fehlt." }, { status: 400 });
}
const body = (await request.json().catch(() => null)) as {
email?: unknown;
displayName?: unknown;
} | null;
if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const email = normalizeEmail(body.email);
const displayName = normalizeDisplayName(body.displayName);
if (!email) {
return NextResponse.json({ error: "E-Mail ist ungültig." }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({
where: {
id: userId
}
});
if (!existingUser) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
}
const emailOwner = await prisma.user.findUnique({
where: {
email
},
select: {
id: true
}
});
if (emailOwner && emailOwner.id !== userId) {
return NextResponse.json({ error: "Diese E-Mail wird bereits verwendet." }, { status: 409 });
}
const user = await prisma.user.update({
where: {
id: userId
},
data: {
email,
displayName
},
select: {
id: true,
email: true,
displayName: true,
role: true,
createdAt: true,
updatedAt: true
}
});
return NextResponse.json({
user
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Benutzer konnte nicht gespeichert werden."
},
{ status: 500 }
);
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const currentUser = await requireCurrentUser();
if (currentUser.role !== "ADMIN") {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const params = await context.params;
const userId = params.id.trim();
if (!userId) {
return NextResponse.json({ error: "Benutzer-ID fehlt." }, { status: 400 });
}
if (userId === currentUser.id) {
return NextResponse.json({ error: "Du kannst deinen eigenen Benutzer hier nicht löschen." }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({
where: {
id: userId
},
select: {
id: true
}
});
if (!existingUser) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
}
await prisma.user.delete({
where: {
id: userId
}
});
await removeUserUploads(userId);
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Benutzer konnte nicht gelöscht werden."
},
{ status: 500 }
);
}
}
+197
View File
@@ -0,0 +1,197 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
normalizeDisplayName,
normalizeEmail,
normalizePassword,
normalizeUserRole
} from "@/lib/validation";
export async function GET() {
try {
const currentUser = await requireCurrentUser();
if (currentUser.role !== "ADMIN") {
return NextResponse.json({ error: "Keine Berechtigung." }, { status: 403 });
}
const users = await prisma.user.findMany({
orderBy: {
createdAt: "asc"
},
select: {
id: true,
email: true,
displayName: true,
role: true,
createdAt: true
}
});
return NextResponse.json({
users
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Nutzer konnten nicht geladen werden." }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const currentUser = await requireCurrentUser();
if (currentUser.role !== "ADMIN") {
return NextResponse.json({ error: "Keine Berechtigung." }, { status: 403 });
}
const body = (await request.json().catch(() => null)) as {
email?: unknown;
password?: unknown;
displayName?: unknown;
role?: unknown;
} | null;
const email = normalizeEmail(body?.email);
const password = normalizePassword(body?.password);
const displayName = normalizeDisplayName(body?.displayName);
const role = normalizeUserRole(body?.role) ?? "USER";
if (!email || !password) {
return NextResponse.json(
{
error: "E-Mail oder Passwort ungültig. Das Passwort muss 10 bis 128 Zeichen lang sein."
},
{ status: 400 }
);
}
const existingUser = await prisma.user.findUnique({
where: {
email
},
select: {
id: true
}
});
if (existingUser) {
return NextResponse.json(
{
error: "Ein Nutzer mit dieser E-Mail existiert bereits."
},
{ status: 409 }
);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName,
role,
settings: {
create: {
darkMode: false,
calendarMaxEvents: 8,
calendarLookaheadDays: 60,
dashboardTitle: "Personal Dashboard",
logoUrl: "/logo.svg",
primaryColor: "#2563eb",
secondaryColor: "#dbeafe"
}
},
widgets: {
create: [
{
type: "favorites",
title: "Links/Favoriten",
position: 0,
x: 0,
y: 0,
w: 3,
h: 6
},
{
type: "note",
title: "Notiz",
position: 1,
x: 3,
y: 0,
w: 3,
h: 4
},
{
type: "search",
title: "Suche",
position: 3,
x: 9,
y: 0,
w: 3,
h: 2
},
{
type: "calendar",
title: "Kalender",
position: 4,
x: 9,
y: 2,
w: 3,
h: 7
}
]
}
},
select: {
id: true,
email: true,
displayName: true,
role: true,
createdAt: true
}
});
const createdWidgets = await prisma.widget.findMany({
where: {
userId: user.id,
type: {
in: ["note"]
}
}
});
await Promise.all(
createdWidgets.map((widget) =>
prisma.noteBoardItem.create({
data: {
id: widget.id,
userId: user.id,
type: widget.type,
title: widget.title,
content: ""
}
})
)
);
return NextResponse.json(
{
user
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Nutzer konnte nicht erstellt werden." }, { status: 500 });
}
}
+199
View File
@@ -0,0 +1,199 @@
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizePositiveInteger, normalizeTitle } from "@/lib/validation";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
function normalizeOpacity(value: unknown, fallback: number): number {
return normalizePositiveInteger(value, fallback, 20, 100);
}
function normalizeWidgetFontSize(value: unknown, fallback: number): number {
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
return fallback;
}
return Math.max(70, Math.min(140, Math.round(numberValue)));
}
function normalizeViewMode(value: unknown, fallback: string): string {
if (value === "grid" || value === "list") {
return value;
}
return fallback === "grid" ? "grid" : "list";
}
async function normalizeWidgetPositions(userId: string, tabId: string | null) {
const widgets = await prisma.widget.findMany({
where: {
userId,
tabId
},
orderBy: [
{
y: "asc"
},
{
x: "asc"
},
{
createdAt: "asc"
}
]
});
await Promise.all(
widgets.map((widget, index) =>
prisma.widget.update({
where: {
id: widget.id
},
data: {
position: index
}
})
)
);
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const body = (await request.json().catch(() => null)) as {
title?: unknown;
position?: unknown;
x?: unknown;
y?: unknown;
w?: unknown;
h?: unknown;
viewMode?: unknown;
opacity?: unknown;
fontSize?: unknown;
calendarNextEventsCount?: unknown;
} | null;
const existingWidget = await prisma.widget.findFirst({
where: {
id: params.id,
userId: user.id
}
});
if (!existingWidget) {
return NextResponse.json({ error: "Widget nicht gefunden." }, { status: 404 });
}
const hasTitle = body && Object.prototype.hasOwnProperty.call(body, "title");
const title = hasTitle ? normalizeTitle(body?.title) : existingWidget.title;
if (!title) {
return NextResponse.json({ error: "Widget-Titel ist ungültig." }, { status: 400 });
}
const widget = await prisma.widget.update({
where: {
id: existingWidget.id
},
data: {
title,
position: normalizePositiveInteger(body?.position, existingWidget.position, 0, 10000),
x: normalizePositiveInteger(body?.x, existingWidget.x, 0, 47),
y: normalizePositiveInteger(body?.y, existingWidget.y, 0, 40000),
w: normalizePositiveInteger(body?.w, existingWidget.w, 1, 48),
h: normalizePositiveInteger(body?.h, existingWidget.h, 1, 180),
opacity: normalizeOpacity(body?.opacity, existingWidget.opacity ?? 100),
fontSize: normalizeWidgetFontSize(body?.fontSize, existingWidget.fontSize ?? 100),
calendarNextEventsCount: normalizePositiveInteger(
body?.calendarNextEventsCount,
existingWidget.calendarNextEventsCount ?? 3,
0,
10
),
viewMode: normalizeViewMode(body?.viewMode, existingWidget.viewMode ?? "list")
}
});
if (widget.type === "note") {
await prisma.noteBoardItem.updateMany({
where: {
id: widget.id,
userId: user.id
},
data: {
title
}
});
}
return NextResponse.json({
widget
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Widget konnte nicht gespeichert werden." }, { status: 500 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireCurrentUser();
const params = await context.params;
const existingWidget = await prisma.widget.findFirst({
where: {
id: params.id,
userId: user.id
},
select: {
id: true,
tabId: true
}
});
if (!existingWidget) {
return NextResponse.json({ error: "Widget nicht gefunden." }, { status: 404 });
}
await prisma.$transaction([
prisma.noteBoardItem.deleteMany({
where: {
id: params.id,
userId: user.id
}
}),
prisma.widget.deleteMany({
where: {
id: params.id,
userId: user.id
}
})
]);
await normalizeWidgetPositions(user.id, existingWidget.tabId);
return NextResponse.json({
ok: true
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Widget konnte nicht gelöscht werden." }, { status: 500 });
}
}
+385
View File
@@ -0,0 +1,385 @@
import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check";
type ExistingWidgetBox = {
x: number;
y: number;
w: number;
h: number;
};
type WidgetDefaults = {
title: string;
w: number;
h: number;
};
const gridColumns = 48;
const widgetCatalog: Record<WidgetType, WidgetDefaults> = {
favorites: {
title: "Links/Favoriten",
w: 12,
h: 36
},
note: {
title: "Notiz",
w: 12,
h: 24
},
search: {
title: "Suche",
w: 16,
h: 12
},
calendar: {
title: "Kalender",
w: 16,
h: 42
},
calculator: {
title: "Taschenrechner",
w: 12,
h: 36
},
clock: {
title: "Uhr",
w: 8,
h: 12
},
"domain-check": {
title: "Domainprüfung",
w: 16,
h: 12
}
};
function normalizeWidgetType(value: unknown): WidgetType | null {
if (
value === "favorites" ||
value === "note" ||
value === "search" ||
value === "calendar" ||
value === "calculator" ||
value === "clock" ||
value === "domain-check"
) {
return value;
}
return null;
}
function clampInteger(value: number, fallback: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.round(value)));
}
function normalizeExistingWidgetBox(widget: ExistingWidgetBox): ExistingWidgetBox {
const width = clampInteger(widget.w, 2, 1, gridColumns);
const height = clampInteger(widget.h, 1, 1, 100);
const x = clampInteger(widget.x, 0, 0, gridColumns - width);
const y = clampInteger(widget.y, 0, 0, 40000);
return {
x,
y,
w: width,
h: height
};
}
function widgetsOverlap(a: ExistingWidgetBox, b: ExistingWidgetBox): boolean {
return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
}
function findFirstAvailablePosition(existingWidgets: ExistingWidgetBox[], width: number, height: number): { x: number; y: number } {
const normalizedExistingWidgets = existingWidgets.map(normalizeExistingWidgetBox);
const safeWidth = clampInteger(width, 1, 1, gridColumns);
const safeHeight = clampInteger(height, 2, 1, 360);
const maxX = gridColumns - safeWidth;
const bottomY = normalizedExistingWidgets.reduce((currentMax, widget) => Math.max(currentMax, widget.y + widget.h), 0);
const scanLimitY = bottomY + safeHeight + 100;
for (let y = 0; y <= scanLimitY; y += 1) {
for (let x = 0; x <= maxX; x += 1) {
const candidate = {
x,
y,
w: safeWidth,
h: safeHeight
};
const hasCollision = normalizedExistingWidgets.some((widget) => widgetsOverlap(candidate, widget));
if (!hasCollision) {
return {
x,
y
};
}
}
}
return {
x: 0,
y: bottomY
};
}
async function ensureDefaultTab(userId: string) {
const existingTab = await prisma.dashboardTab.findFirst({
where: {
userId
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
if (existingTab) {
await prisma.widget.updateMany({
where: {
userId,
tabId: null
},
data: {
tabId: existingTab.id
}
});
return existingTab;
}
const tab = await prisma.dashboardTab.create({
data: {
userId,
title: "Dashboard",
position: 0
}
});
await prisma.widget.updateMany({
where: {
userId,
tabId: null
},
data: {
tabId: tab.id
}
});
return tab;
}
async function requireUserTab(userId: string, tabId: string) {
const tab = await prisma.dashboardTab.findFirst({
where: {
id: tabId,
userId
}
});
if (!tab) {
throw new Error("Dashboard-Tab nicht gefunden.");
}
return tab;
}
async function migrateLegacyNoteboardWidgets(userId: string) {
const legacyWidgets = await prisma.widget.findMany({
where: {
userId,
type: "noteboard"
}
});
for (const widget of legacyWidgets) {
await prisma.$transaction(async (tx) => {
await tx.widget.update({
where: {
id: widget.id
},
data: {
type: "note",
title: widget.title?.trim() || "Notiz"
}
});
const existingNote = await tx.noteBoardItem.findFirst({
where: {
id: widget.id,
userId
},
select: {
id: true
}
});
if (!existingNote) {
await tx.noteBoardItem.create({
data: {
id: widget.id,
userId,
type: "note",
title: widget.title?.trim() || "Notiz",
content: ""
}
});
}
});
}
}
export async function GET(request: NextRequest) {
try {
const user = await requireCurrentUser();
const defaultTab = await ensureDefaultTab(user.id);
await migrateLegacyNoteboardWidgets(user.id);
const requestedTabId = request.nextUrl.searchParams.get("tabId")?.trim() || null;
if (requestedTabId) {
await requireUserTab(user.id, requestedTabId);
}
const widgets = await prisma.widget.findMany({
where: {
userId: user.id,
...(requestedTabId ? { tabId: requestedTabId } : {})
},
orderBy: [
{
position: "asc"
},
{
createdAt: "asc"
}
]
});
return NextResponse.json({
defaultTabId: defaultTab.id,
widgets
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Widgets konnten nicht geladen werden." },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
const body = (await request.json().catch(() => null)) as {
type?: unknown;
tabId?: unknown;
} | null;
const type = normalizeWidgetType(body?.type);
if (!type) {
return NextResponse.json({ error: "Widget-Typ ist ungültig." }, { status: 400 });
}
const defaultTab = await ensureDefaultTab(user.id);
const requestedTabId = typeof body?.tabId === "string" && body.tabId.trim() ? body.tabId.trim() : defaultTab.id;
const tab = await requireUserTab(user.id, requestedTabId);
const existingWidgets = await prisma.widget.findMany({
where: {
userId: user.id,
tabId: tab.id
},
select: {
x: true,
y: true,
w: true,
h: true,
position: true
},
orderBy: [
{
y: "asc"
},
{
x: "asc"
},
{
createdAt: "asc"
}
]
});
const maxPosition = existingWidgets.reduce((currentMax, widget) => Math.max(currentMax, widget.position), -1);
const defaults = widgetCatalog[type];
const firstAvailablePosition = findFirstAvailablePosition(existingWidgets, defaults.w, defaults.h);
const widget = await prisma.$transaction(async (tx) => {
const createdWidget = await tx.widget.create({
data: {
userId: user.id,
tabId: tab.id,
type,
title: defaults.title,
position: maxPosition + 1,
x: firstAvailablePosition.x,
y: firstAvailablePosition.y,
w: defaults.w,
h: defaults.h,
opacity: 100
}
});
if (type === "note") {
await tx.noteBoardItem.create({
data: {
id: createdWidget.id,
userId: user.id,
type: "note",
title: defaults.title,
content: ""
}
});
}
return createdWidget;
});
return NextResponse.json(
{
widget
},
{ status: 201 }
);
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Widget konnte nicht erstellt werden." },
{ status: 500 }
);
}
}
+347
View File
@@ -0,0 +1,347 @@
.widgetCard-calendar {
overflow: visible !important;
}
.widgetCard-calendar .widgetContent {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
gap: clamp(5px, 1.3cqh, 10px);
overflow: hidden !important;
padding: clamp(7px, 1.7cqw, 12px);
}
.widgetCard-calendar .calendarSourcePanel {
max-height: min(70cqh, 360px);
overflow: auto;
}
.widgetCard-calendar .calendarHeader {
display: grid !important;
grid-template-columns: minmax(58px, 1fr) minmax(82px, 1.15fr) minmax(58px, 1fr);
gap: clamp(4px, 1cqw, 8px);
align-items: center;
min-height: 0;
margin: 0;
}
.widgetCard-calendar .calendarNavButton,
.widgetCard-calendar .calendarMonthButton {
min-height: clamp(26px, 7cqh, 34px);
padding: 0 clamp(5px, 1.5cqw, 9px);
border-radius: 10px;
font-size: clamp(11px, 2.8cqw, 13px);
line-height: 1;
}
.widgetCard-calendar .calendarMonthButton {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widgetCard-calendar .calendarWeekdays {
display: grid !important;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: clamp(2px, 0.8cqw, 4px);
min-height: 0;
margin: 0;
}
.widgetCard-calendar .calendarWeekday {
font-size: clamp(10px, 2.3cqw, 12px);
line-height: 1.1;
}
.widgetCard-calendar .calendarGrid {
display: grid !important;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: clamp(2px, 0.8cqw, 4px);
min-height: 0;
overflow: visible !important;
}
.widgetCard-calendar .calendarDay {
position: relative;
min-height: clamp(20px, 6.5cqh, 36px);
padding: clamp(3px, 0.9cqw, 5px);
border-radius: clamp(7px, 1.7cqw, 10px);
overflow: visible !important;
}
.widgetCard-calendar .calendarDayNumber {
font-size: clamp(10px, 2.5cqw, 13px);
line-height: 1;
}
.widgetCard-calendar .calendarEventCount {
right: clamp(2px, 0.7cqw, 4px);
bottom: clamp(2px, 0.7cqw, 4px);
min-width: clamp(14px, 3.7cqw, 18px);
height: clamp(14px, 3.7cqw, 18px);
padding: 0 clamp(2px, 0.7cqw, 4px);
font-size: clamp(8px, 1.9cqw, 10px);
line-height: 1;
}
.widgetCard-calendar .calendarTooltip {
z-index: 8000;
width: min(280px, 72vw);
max-width: 280px;
pointer-events: none;
}
.widgetCard-calendar .nextEventsBlock {
min-height: 0;
display: grid !important;
grid-template-rows: auto minmax(0, 1fr);
gap: clamp(5px, 1.2cqh, 8px);
margin: 0;
padding-top: clamp(6px, 1.4cqh, 10px);
overflow: hidden !important;
}
.widgetCard-calendar .nextEventsBlock h3 {
margin: 0;
font-size: clamp(12px, 2.7cqw, 15px);
line-height: 1.15;
}
.widgetCard-calendar .eventList {
min-height: 0;
display: grid;
gap: clamp(4px, 1cqh, 7px);
overflow: hidden !important;
}
.widgetCard-calendar .eventItem {
min-height: 0;
padding: clamp(5px, 1.3cqw, 8px) clamp(6px, 1.5cqw, 10px);
border-radius: 10px;
overflow: hidden;
}
.widgetCard-calendar .eventDate {
overflow: hidden;
color: var(--muted);
font-size: clamp(10px, 2.2cqw, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.widgetCard-calendar .eventTitle {
display: -webkit-box;
overflow: hidden;
font-size: clamp(11px, 2.5cqw, 14px);
line-height: 1.2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.widgetCard-calendar .eventLocation {
overflow: hidden;
color: var(--muted);
font-size: clamp(10px, 2.1cqw, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.react-grid-item:has(.widgetCard-calendar:hover),
.react-grid-item:has(.calendarDay:hover) {
z-index: 900 !important;
}
.react-grid-item:has(.widgetCard-calendar:hover) .widgetCard-calendar,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .widgetContent,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .calendarGrid,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .calendarDay {
overflow: visible !important;
}
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .nextEventsBlock,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .eventList,
.react-grid-item:has(.calendarDay:hover) .widgetCard-calendar .eventItem {
overflow: hidden !important;
}
@container (max-height: 360px) {
.widgetCard-calendar .widgetContent {
gap: 6px;
padding: 8px;
}
.widgetCard-calendar .calendarNavButton,
.widgetCard-calendar .calendarMonthButton {
min-height: 28px;
font-size: 12px;
}
.widgetCard-calendar .calendarDay {
min-height: 26px;
}
.widgetCard-calendar .nextEventsBlock {
padding-top: 6px;
}
.widgetCard-calendar .eventTitle {
-webkit-line-clamp: 1;
}
.widgetCard-calendar .eventLocation {
display: none;
}
}
@container (max-height: 290px) {
.widgetCard-calendar .widgetContent {
grid-template-rows: auto auto auto minmax(0, 1fr);
gap: 5px;
padding: 7px;
}
.widgetCard-calendar .calendarHeader {
gap: 4px;
}
.widgetCard-calendar .calendarNavButton,
.widgetCard-calendar .calendarMonthButton {
min-height: 25px;
font-size: 11px;
}
.widgetCard-calendar .calendarWeekday {
font-size: 10px;
}
.widgetCard-calendar .calendarDay {
min-height: 22px;
padding: 3px;
}
.widgetCard-calendar .calendarDayNumber {
font-size: 11px;
}
.widgetCard-calendar .calendarEventCount {
min-width: 14px;
height: 14px;
font-size: 8px;
}
.widgetCard-calendar .nextEventsBlock h3 {
font-size: 12px;
}
.widgetCard-calendar .eventItem {
padding-top: 4px;
padding-bottom: 4px;
}
.widgetCard-calendar .eventDate {
font-size: 10px;
}
.widgetCard-calendar .eventTitle {
font-size: 11px;
}
.widgetCard-calendar .eventList .eventItem:nth-child(n + 3) {
display: none;
}
}
@container (max-height: 230px) {
.widgetCard-calendar .widgetContent {
gap: 4px;
padding: 6px;
}
.widgetCard-calendar .calendarHeader {
grid-template-columns: minmax(44px, 1fr) minmax(68px, 1.15fr) minmax(44px, 1fr);
}
.widgetCard-calendar .calendarNavButton,
.widgetCard-calendar .calendarMonthButton {
min-height: 22px;
padding: 0 5px;
font-size: 10px;
}
.widgetCard-calendar .calendarGrid {
gap: 3px;
}
.widgetCard-calendar .calendarWeekdays {
gap: 3px;
}
.widgetCard-calendar .calendarDay {
min-height: 18px;
border-radius: 6px;
}
.widgetCard-calendar .calendarDayNumber {
font-size: 10px;
}
.widgetCard-calendar .nextEventsBlock {
padding-top: 4px;
}
.widgetCard-calendar .nextEventsBlock h3 {
display: none;
}
.widgetCard-calendar .eventList {
gap: 3px;
}
.widgetCard-calendar .eventItem {
padding: 3px 5px;
}
.widgetCard-calendar .eventDate {
display: none;
}
.widgetCard-calendar .eventTitle {
font-size: 10px;
line-height: 1.15;
}
.widgetCard-calendar .eventList .eventItem:nth-child(n + 2) {
display: none;
}
}
@container (max-height: 175px) {
.widgetCard-calendar .widgetContent {
grid-template-rows: auto auto auto;
}
.widgetCard-calendar .nextEventsBlock {
display: none !important;
}
}
@container (max-width: 260px) {
.widgetCard-calendar .calendarHeader {
grid-template-columns: 1fr;
}
.widgetCard-calendar .calendarNavButton,
.widgetCard-calendar .calendarMonthButton {
min-height: 22px;
}
.widgetCard-calendar .calendarTooltip {
left: 0;
right: auto;
}
}
+204
View File
@@ -0,0 +1,204 @@
/* Isoliertes Uhr-Widget.
Diese Datei muss zuletzt importiert werden. */
.app .widgetCard-clock {
position: relative !important;
min-height: 0 !important;
overflow: hidden !important;
}
/* Normalmodus: komplette Widget-Fläche für die Uhr verwenden */
.app .widgetCard-clock:not(.widgetCardEditMode) .widgetHeader {
height: 0 !important;
min-height: 0 !important;
max-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.app .widgetCard-clock:not(.widgetCardEditMode) .widgetContent {
position: absolute !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 0 !important;
overflow: hidden !important;
}
/* Bearbeitungsmodus: oben 24px Platz für Griff/Menü lassen */
.app .widgetCard-clock.widgetCardEditMode .widgetHeader {
height: 24px !important;
min-height: 24px !important;
max-height: 24px !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetContent {
position: absolute !important;
inset: 24px 0 0 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 0 !important;
overflow: hidden !important;
}
.app .widgetCard-clock .widgetContent > .pdClockWidget {
width: 100% !important;
height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
overflow: hidden !important;
opacity: 1 !important;
visibility: visible !important;
}
.app .pdClockWidget {
width: 100% !important;
height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
overflow: hidden !important;
color: var(--text) !important;
opacity: 1 !important;
visibility: visible !important;
}
.app .pdClockCenter {
width: 100% !important;
height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
grid-template-rows: auto auto !important;
justify-items: center !important;
align-content: center !important;
gap: 2px !important;
padding: 2px 4px !important;
box-sizing: border-box !important;
text-align: center !important;
overflow: hidden !important;
}
.app .pdClockWidgetEditMode .pdClockCenter {
grid-template-rows: auto auto auto !important;
gap: 3px !important;
padding: 1px 4px 4px !important;
}
.app .pdClockTime {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
color: var(--text) !important;
opacity: 1 !important;
visibility: visible !important;
font-weight: 900 !important;
line-height: 0.9 !important;
letter-spacing: -0.045em !important;
white-space: nowrap !important;
text-align: center !important;
font-variant-numeric: tabular-nums !important;
text-indent: 0 !important;
transform: translateX(-0.015em) !important;
filter: none !important;
}
.app .pdClockDate {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
color: color-mix(in srgb, var(--text) 74%, transparent) !important;
opacity: 1 !important;
visibility: visible !important;
font-weight: 750 !important;
line-height: 1.1 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
text-align: center !important;
text-indent: 0 !important;
transform: none !important;
filter: none !important;
}
.app .pdClockDateControl {
width: min(100%, 210px) !important;
display: grid !important;
margin-top: 2px !important;
}
.app .pdClockDateControlLabel {
position: absolute !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
}
.app .pdClockDateSelect {
width: 100% !important;
height: 30px !important;
min-height: 30px !important;
padding: 0 28px 0 10px !important;
color: var(--text) !important;
background: color-mix(in srgb, var(--surface) 82%, transparent) !important;
border: 1px solid var(--border) !important;
border-radius: 10px !important;
font: inherit !important;
font-size: 12px !important;
font-weight: 650 !important;
outline: none !important;
}
.app .pdClockDateSelect:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent) !important;
}
/* FIX: Uhr-Widget-Menü darf nicht vom Widget abgeschnitten werden */
.app .widgetCard-clock.widgetCardMenuOpen {
overflow: visible !important;
z-index: 1200 !important;
}
.app .widgetCard-clock.widgetCardMenuOpen .widgetHeader {
overflow: visible !important;
z-index: 1300 !important;
}
.app .widgetCard-clock.widgetCardMenuOpen .widgetMenu {
z-index: 1400 !important;
}
.app .widgetCard-clock.widgetCardMenuOpen .widgetDropdown {
z-index: 1500 !important;
max-height: min(260px, 70vh) !important;
overflow-y: auto !important;
overscroll-behavior: contain !important;
}
/* Falls der React-Grid-Container ebenfalls clippt */
.react-grid-item:has(.widgetCard-clock.widgetCardMenuOpen) {
overflow: visible !important;
z-index: 1200 !important;
}
+378
View File
@@ -0,0 +1,378 @@
/* =========================================================
Compact Widgets
Ziel: Widgets selbst schlanker machen, ohne Funktionen zu entfernen
========================================================= */
/* Widget-Grundlayout */
.widgetCard {
border-radius: 14px !important;
}
.widgetHeader {
min-height: 24px !important;
padding: 3px 7px 1px !important;
gap: 5px !important;
}
.widgetCardEditMode .widgetHeader {
padding-left: 30px !important;
padding-right: 32px !important;
}
.widgetContent {
padding: 5px 7px 7px !important;
gap: 6px !important;
}
.widgetTitle {
min-height: 22px !important;
}
.widgetTitle h2 {
margin: 0 !important;
font-size: calc(13px * var(--widget-font-scale, 1)) !important;
line-height: 1.08 !important;
}
.widgetTitleInput {
min-height: 22px !important;
height: 22px !important;
padding: 0 6px !important;
font-size: calc(13px * var(--widget-font-scale, 1)) !important;
line-height: 22px !important;
}
/* Kleine Header-Actions */
.widgetDragHandle {
top: 3px !important;
left: 6px !important;
width: 19px !important;
height: 21px !important;
}
.widgetMenu {
top: 3px !important;
right: 6px !important;
}
.widgetMenuButton {
width: 22px !important;
min-width: 22px !important;
height: 22px !important;
min-height: 22px !important;
}
.noteHeaderEditButton {
width: 24px !important;
min-width: 24px !important;
height: 24px !important;
}
/* Dropdown kompakter */
.widgetDropdown {
top: 25px !important;
min-width: 178px !important;
padding: 5px !important;
gap: 3px !important;
}
.widgetDropdownButton {
min-height: 28px !important;
padding: 0 8px !important;
font-size: 12px !important;
}
/* Allgemeine Controls in Widgets */
.widgetCard .button,
.widgetCard .buttonSecondary,
.widgetCard .input,
.widgetCard .select,
.widgetCard .searchInput {
min-height: 28px !important;
font-size: 12px !important;
border-radius: 8px !important;
}
.widgetCard .button,
.widgetCard .buttonSecondary {
padding: 0 9px !important;
}
.widgetCard .input,
.widgetCard .select,
.widgetCard .searchInput {
padding: 0 8px !important;
}
/* Formularabstände */
.widgetCard form,
.widgetCard .fieldLabel,
.widgetCard .settingsButtonRow {
gap: 5px !important;
}
.widgetCard .muted,
.widgetCard .errorText,
.widgetCard .successText {
margin-top: 3px !important;
margin-bottom: 3px !important;
font-size: 12px !important;
line-height: 1.25 !important;
}
/* Links/Favoriten Liste */
.favoriteTileList,
.favoritesList {
gap: 5px !important;
}
.favoriteSortableItem,
.favoriteItem {
gap: 5px !important;
}
.favoriteTile {
min-height: 32px !important;
padding: 5px 7px !important;
gap: 6px !important;
border-radius: 10px !important;
}
.favoriteIcon {
width: 24px !important;
height: 24px !important;
min-width: 24px !important;
}
.favoriteTitle {
font-size: calc(12px * var(--widget-font-scale, 1)) !important;
line-height: 1.15 !important;
}
.favoriteItemActions {
gap: 4px !important;
}
.favoriteEditButton,
.favoriteDeleteButton {
width: 26px !important;
min-width: 26px !important;
height: 26px !important;
}
.favoriteActionIcon {
width: 15px !important;
height: 15px !important;
}
.favoriteAddForm {
gap: 5px !important;
margin-top: 6px !important;
}
.favoriteInlineEditForm {
gap: 6px !important;
margin-top: 6px !important;
padding: 8px !important;
}
/* Links/Favoriten Kachelmodus */
.favoritesWidgetGridMode .favoriteTileList {
gap: 6px !important;
}
.favoritesWidgetGridMode .favoriteTile {
min-height: 62px !important;
padding: 5px 4px !important;
gap: 3px !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIcon {
width: clamp(18px, 44cqi, 32px) !important;
height: clamp(18px, 44cqi, 32px) !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
font-size: clamp(8px, 15cqi, 10.5px) !important;
line-height: 1.08 !important;
}
/* Notiz Widget */
.singleNoteWidget,
.noteMarkdownEditor {
gap: 5px !important;
}
.noteMarkdownToolbar {
gap: 3px !important;
padding: 3px !important;
border-radius: 9px !important;
}
.noteMarkdownToolbar button,
.markdownToolbarButton {
min-width: 25px !important;
min-height: 24px !important;
height: 24px !important;
padding: 0 6px !important;
font-size: 11px !important;
}
.noteTextarea,
.singleNoteTextarea {
padding: 7px !important;
font-size: 12px !important;
line-height: 1.35 !important;
border-radius: 10px !important;
}
.noteMarkdownPreview {
padding: 6px !important;
font-size: 12px !important;
line-height: 1.35 !important;
}
.noteMarkdownPreview p,
.noteMarkdownPreview ul,
.noteMarkdownPreview ol {
margin-top: 4px !important;
margin-bottom: 4px !important;
}
/* Suche */
.searchWidgetForm {
gap: 5px !important;
}
.widgetCard-search .searchWidgetForm {
gap: 5px !important;
}
.widgetCard-search .searchInput {
min-height: 28px !important;
}
/* Kalender */
.calendarHeader {
gap: 4px !important;
margin-bottom: 4px !important;
}
.calendarNavButton,
.calendarMonthButton {
min-height: 26px !important;
padding: 0 7px !important;
font-size: 11px !important;
}
.calendarWeekdays {
gap: 3px !important;
margin-bottom: 3px !important;
}
.calendarWeekday {
font-size: 10px !important;
line-height: 1 !important;
}
.calendarGrid {
gap: 3px !important;
}
.calendarDay {
min-height: 24px !important;
padding: 2px !important;
border-radius: 7px !important;
}
.calendarDayNumber {
font-size: 10px !important;
line-height: 1 !important;
}
.calendarEventCount {
min-width: 13px !important;
height: 13px !important;
font-size: 9px !important;
line-height: 13px !important;
}
.nextEventsBlock {
margin-top: 6px !important;
}
.nextEventsBlock h3 {
margin: 0 0 4px !important;
font-size: 12px !important;
}
.eventList {
gap: 5px !important;
}
.eventItem {
padding: 6px !important;
border-radius: 9px !important;
}
.eventDate,
.eventLocation {
font-size: 10.5px !important;
}
.eventTitle {
font-size: 12px !important;
line-height: 1.2 !important;
}
/* Kalenderquelle im Editmodus */
.calendarSourcePanel {
margin-bottom: 6px !important;
}
.calendarSourceForm {
gap: 6px !important;
padding-top: 6px !important;
}
/* Taschenrechner im Widget etwas dichter */
.widgetCard-calculator .widgetContent {
padding: 5px !important;
}
/* Domaincheck */
.domainCheckWidget {
gap: 7px !important;
}
.domainCheckField {
gap: 4px !important;
font-size: 11px !important;
}
.domainCheckInputRow {
gap: 5px !important;
}
.domainCheckResult {
gap: 6px !important;
padding: 8px !important;
border-radius: 10px !important;
}
.domainCheckStatusBadge {
padding: 2px 7px !important;
font-size: 10px !important;
}
/* Uhr */
.widgetCard-clock .widgetContent {
padding: 2px 4px 4px !important;
}
/* Generell weniger Innenabstand bei sehr kleinen Widgets */
.react-grid-item[style*="height: 20px"] .widgetContent,
.react-grid-item[style*="height: 30px"] .widgetContent,
.react-grid-item[style*="height: 40px"] .widgetContent {
padding: 3px 5px !important;
}
+115
View File
@@ -0,0 +1,115 @@
.domainCheckWidget {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
.domainCheckInput {
min-width: 0;
height: 30px;
min-height: 30px;
}
.domainCheckButton {
height: 30px;
min-height: 30px;
padding: 0 10px;
white-space: nowrap;
}
.domainCheckStatus {
width: auto;
min-width: 18px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 7px;
color: var(--muted);
background: color-mix(in srgb, var(--surface) 82%, transparent);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
font-weight: 900;
line-height: 1;
white-space: nowrap;
}
.domainCheckStatusDot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 999px;
background: currentColor;
opacity: 0.75;
}
.domainCheckStatus-idle {
color: var(--muted);
}
.domainCheckStatus-loading {
color: #facc15;
border-color: color-mix(in srgb, #facc15 55%, var(--border));
}
.domainCheckStatus-available {
color: #22c55e;
border-color: color-mix(in srgb, #22c55e 68%, var(--border));
background: color-mix(in srgb, #22c55e 12%, transparent);
}
.domainCheckStatus-registered {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 68%, var(--border));
background: color-mix(in srgb, #ef4444 12%, transparent);
}
.domainCheckStatus-invalid,
.domainCheckStatus-unknown {
color: #f59e0b;
border-color: color-mix(in srgb, #f59e0b 68%, var(--border));
background: color-mix(in srgb, #f59e0b 12%, transparent);
}
.domainCheckStatus-idle .domainCheckStatusDot {
opacity: 0.35;
}
.domainCheckStatus-idle .domainCheckStatusText {
display: none;
}
@container (max-width: 260px) {
.domainCheckWidget {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 4px;
}
.domainCheckStatus {
width: 18px;
min-width: 18px;
padding: 0;
}
.domainCheckStatusText {
display: none;
}
.domainCheckButton {
width: 34px;
min-width: 34px;
padding: 0;
overflow: hidden;
font-size: 0;
}
.domainCheckButton::before {
content: "✓";
font-size: 13px;
}
}
+424
View File
@@ -0,0 +1,424 @@
/* Links/Favoriten: Symbolbuttons und Inline-Bearbeitung */
.favoriteSortableItem {
position: relative;
}
.favoriteItemActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.favoriteEditButton,
.favoriteDeleteButton {
width: 30px;
min-width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--text);
background: color-mix(in srgb, var(--surface) 86%, transparent);
border: 1px solid var(--border);
border-radius: 9px;
cursor: pointer;
line-height: 1;
}
.favoriteActionIcon {
width: 17px;
height: 17px;
display: block;
}
.favoriteEditButton:hover {
color: var(--accent);
border-color: var(--accent);
}
.favoriteDeleteButton:hover {
color: #fecaca;
background: color-mix(in srgb, #dc2626 22%, var(--surface));
border-color: #ef4444;
}
.favoriteInlineEditForm {
grid-column: 1 / -1;
display: grid;
gap: 8px;
margin-top: 8px;
padding: 10px;
background: color-mix(in srgb, var(--surface-strong) 88%, transparent);
border: 1px solid var(--border);
border-radius: 12px;
}
.favoriteInlineEditActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.favoriteInlineEditActions .button {
min-height: 30px;
padding: 0 10px;
}
.favoriteSortableItemDragging {
opacity: 0.55;
}
/* Links/Favoriten: Darstellungsumschalter */
.favoriteViewModeToggle {
display: inline-flex;
align-items: center;
gap: 4px;
width: fit-content;
padding: 3px;
background: color-mix(in srgb, var(--surface-strong) 86%, transparent);
border: 1px solid var(--border);
border-radius: 999px;
}
.favoriteViewModeButton {
min-height: 26px;
padding: 0 10px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
font-size: 12px;
font-weight: 800;
}
.favoriteViewModeButtonActive {
color: #fff;
background: var(--accent);
}
/* Kachelmodus: mehrere Favoriten nebeneinander, Text unter dem Logo */
.favoritesWidgetGridMode .favoriteTileList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(74px, 1fr));
align-items: start;
gap: 8px;
}
.favoritesWidgetGridMode .favoriteSortableItem {
display: grid;
justify-items: center;
gap: 6px;
min-width: 0;
}
.favoritesWidgetGridMode .favoriteTile {
width: 100%;
min-width: 0;
display: grid;
justify-items: center;
align-items: center;
gap: 5px;
padding: 8px 6px;
text-align: center;
}
.favoritesWidgetGridMode .favoriteIcon {
width: 34px;
height: 34px;
}
.favoritesWidgetGridMode .favoriteTitle {
width: 100%;
min-width: 0;
display: block;
overflow: hidden;
font-size: calc(11px * var(--widget-font-scale, 1));
line-height: 1.15;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.favoritesWidgetGridMode .favoriteItemActions {
justify-content: center;
}
.favoritesWidgetGridMode .favoriteInlineEditForm {
width: 100%;
}
/* Kachelmodus-Fix: Icon oben, Beschriftung darunter */
.favoritesWidgetGridMode .favoriteTileList {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
gap: 8px;
align-items: start;
}
.favoritesWidgetGridMode .favoriteSortableItem {
display: grid !important;
justify-items: center;
align-items: start;
gap: 6px;
min-width: 0;
}
.favoritesWidgetGridMode .favoriteTile {
width: 100%;
min-width: 0;
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: auto auto !important;
grid-auto-flow: row !important;
justify-items: center !important;
align-items: center !important;
gap: 6px !important;
padding: 8px 6px !important;
text-align: center !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIcon {
grid-column: 1 !important;
grid-row: 1 !important;
width: 34px;
height: 34px;
margin: 0 auto;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
grid-column: 1 !important;
grid-row: 2 !important;
width: 100%;
min-width: 0;
display: block;
margin: 0;
overflow: hidden;
font-size: calc(11px * var(--widget-font-scale, 1));
line-height: 1.15;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* FINAL: Kachelmodus sauber ausrichten */
.favoritesWidgetGridMode .favoriteTileList {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)) !important;
gap: 10px !important;
align-items: start !important;
}
.favoritesWidgetGridMode .favoriteSortableItem {
display: grid !important;
grid-template-columns: 1fr !important;
justify-items: stretch !important;
align-items: start !important;
gap: 6px !important;
min-width: 0 !important;
}
.favoritesWidgetGridMode .favoriteTile {
width: 100% !important;
min-width: 0 !important;
height: 82px !important;
min-height: 82px !important;
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 44px minmax(18px, auto) !important;
justify-items: center !important;
align-items: center !important;
gap: 4px !important;
padding: 8px 7px 7px !important;
text-align: center !important;
overflow: hidden !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIcon {
grid-column: 1 !important;
grid-row: 1 !important;
width: 38px !important;
height: 38px !important;
min-width: 38px !important;
min-height: 38px !important;
margin: 0 auto !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIconImage {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIconFallback {
font-size: 11px !important;
line-height: 1 !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
grid-column: 1 !important;
grid-row: 2 !important;
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
display: -webkit-box !important;
margin: 0 !important;
overflow: hidden !important;
color: var(--text) !important;
font-size: calc(11px * var(--widget-font-scale, 1)) !important;
font-weight: 800 !important;
line-height: 1.15 !important;
text-align: center !important;
text-overflow: ellipsis !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
}
.favoritesWidgetGridMode .favoriteItemActions {
justify-content: center !important;
}
.favoritesWidgetGridMode .favoriteInlineEditForm {
width: 100% !important;
grid-column: 1 / -1 !important;
}
/* Kachelmodus: Icons proportional verkleinern, wenn die Kachel schmal wird */
.favoritesWidgetGridMode .favoriteTile {
height: auto !important;
min-height: 76px !important;
grid-template-rows: auto auto !important;
align-content: center !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIcon {
width: clamp(24px, 46%, 38px) !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
aspect-ratio: 1 / 1 !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIconImage {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
margin-top: 2px !important;
}
/* FINAL: Kachelmodus responsiv ohne Überlauf */
.favoritesWidgetGridMode .favoriteTileList {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)) !important;
gap: clamp(4px, 1.4vw, 10px) !important;
align-items: start !important;
overflow-x: hidden !important;
}
.favoritesWidgetGridMode .favoriteSortableItem {
min-width: 0 !important;
width: 100% !important;
display: grid !important;
justify-items: stretch !important;
}
.favoritesWidgetGridMode .favoriteTile {
container-type: inline-size;
width: 100% !important;
min-width: 0 !important;
height: auto !important;
min-height: clamp(52px, 16cqw, 82px) !important;
display: grid !important;
grid-template-columns: minmax(0, 1fr) !important;
grid-template-rows: auto auto !important;
justify-items: center !important;
align-content: center !important;
gap: clamp(2px, 4cqi, 6px) !important;
padding: clamp(4px, 8cqi, 8px) clamp(3px, 7cqi, 7px) !important;
overflow: hidden !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIcon {
width: clamp(20px, 48cqi, 38px) !important;
height: clamp(20px, 48cqi, 38px) !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100% !important;
max-height: 38px !important;
margin: 0 auto !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteIconImage {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
display: block !important;
overflow: hidden !important;
font-size: clamp(8px, 16cqi, 11px) !important;
line-height: 1.1 !important;
text-align: center !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
@container (max-width: 68px) {
.favoritesWidgetGridMode .favoriteTile {
border-radius: 8px !important;
}
.favoritesWidgetGridMode .favoriteTile .favoriteTitle {
font-size: 8px !important;
}
}
/* Fix: Liste/Kacheln-Umschalter darf nicht mit der Widgetgröße skalieren */
.favoriteViewModeToggle {
width: max-content !important;
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
flex: 0 0 auto !important;
align-self: flex-start !important;
justify-self: start !important;
display: inline-flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 4px !important;
padding: 3px !important;
box-sizing: border-box !important;
overflow: hidden !important;
border-radius: 999px !important;
}
.favoriteViewModeButton {
height: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 10px !important;
line-height: 1 !important;
box-sizing: border-box !important;
}
.favoritesWidgetGridMode .favoriteViewModeToggle {
width: max-content !important;
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
}
+3382
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import BrowserChrome from "@/components/BrowserChrome";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import "./globals.css";
import "./domain-check-widget.css";
import "./favorites-widget.css";
import "./admin.css";
import "./note-widget.css";
import "./calendar-scale.css";
import "./toolbar-fixes.css";
import "./user-theme.css";
import "./widget-density.css";
import "./clock-widget.css";
import "./search-widget.css";
export const metadata: Metadata = {
title: "Personal Dashboard",
description: "Personal Dashboard"
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body><BrowserChrome />{children}</body>
</html>
);
}
+210
View File
@@ -0,0 +1,210 @@
/* Notiz-Widget: Markdown-Editor und Vorschau */
.noteMarkdownEditor {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
}
.noteMarkdownToolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 6px;
background: color-mix(in srgb, var(--surface-strong) 88%, transparent);
border: 1px solid var(--border);
border-radius: 10px;
}
.noteMarkdownButton {
min-width: 32px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 9px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: calc(12px * var(--widget-font-scale, 1));
font-weight: 700;
line-height: 1;
}
.noteMarkdownButton:hover {
border-color: var(--accent);
}
.noteMarkdownButtonWide {
min-width: 46px;
}
.noteMarkdownEditor .noteTextarea,
.noteMarkdownEditor .singleNoteTextarea {
height: 100%;
min-height: 0;
resize: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.noteMarkdownPreview {
height: 100%;
min-height: 0;
overflow: auto;
padding: 8px 10px;
color: var(--text);
font-size: calc(14px * var(--widget-font-scale, 1));
line-height: 1.45;
}
.noteMarkdownParagraph {
margin: 0 0 8px;
}
.noteMarkdownParagraph:last-child {
margin-bottom: 0;
}
.noteMarkdownHeading {
margin: 0 0 8px;
font-weight: 800;
line-height: 1.2;
}
.noteMarkdownHeading1 {
font-size: calc(21px * var(--widget-font-scale, 1));
}
.noteMarkdownHeading2 {
font-size: calc(18px * var(--widget-font-scale, 1));
}
.noteMarkdownHeading3 {
font-size: calc(16px * var(--widget-font-scale, 1));
}
.noteMarkdownList {
margin: 0 0 8px;
padding-left: 22px;
}
.noteMarkdownList li {
margin: 2px 0;
}
.noteMarkdownChecklist {
display: grid;
gap: 5px;
margin: 0 0 8px;
}
.noteMarkdownTask {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
min-height: 24px;
cursor: pointer;
}
.noteMarkdownTask input {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.noteMarkdownTask span {
min-width: 0;
}
.noteMarkdownPreview strong {
font-weight: 800;
}
.noteMarkdownPreview em {
font-style: italic;
}
.noteMarkdownPreview code {
padding: 2px 5px;
background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
border: 1px solid var(--border);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.92em;
}
.noteMarkdownPreview a {
color: var(--accent);
text-decoration: none;
font-weight: 700;
}
.noteMarkdownPreview a:hover {
text-decoration: underline;
}
.noteMarkdownSpacer {
height: 8px;
}
@container (max-height: 140px) {
.noteMarkdownToolbar {
gap: 4px;
padding: 4px;
}
.noteMarkdownButton {
min-width: 28px;
height: 26px;
padding: 0 7px;
font-size: calc(11px * var(--widget-font-scale, 1));
}
.noteMarkdownPreview {
padding: 6px 8px;
}
}
/* Notiz-Widget: Stiftbutton im Widget-Header */
.noteHeaderEditButton {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--muted);
background: color-mix(in srgb, var(--surface) 86%, transparent);
border: 1px solid var(--border);
border-radius: 9px;
cursor: pointer;
line-height: 1;
}
.noteHeaderEditButton:hover {
color: var(--accent);
border-color: var(--accent);
}
.noteHeaderEditButtonActive {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.noteHeaderEditButtonActive:hover {
color: #fff;
}
.noteHeaderEditIcon {
width: 16px;
height: 16px;
display: block;
}
+2370
View File
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
/* Such-Widget
Einheitliche Widget-Chrome bleibt erhalten:
- normaler Rahmen
- Bearbeitungsmenü bleibt sichtbar
- Resize-Ecke bleibt frei
- kein Widget-Titel
- Suchzeile mittig und kompakt
*/
.app .widgetCard-search {
position: relative !important;
border: 1px solid var(--border) !important;
background: color-mix(in srgb, var(--surface) 82%, transparent) !important;
border-radius: 14px !important;
overflow: visible !important;
}
/* Titel ausblenden, aber Header-Struktur fuer Menu/Griff erhalten */
.app .widgetCard-search .widgetTitle {
display: none !important;
}
/* Normalmodus: kein leerer Titelbereich */
.app .widgetCard-search:not(.widgetCardEditMode) .widgetHeader {
height: 0 !important;
min-height: 0 !important;
max-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden !important;
}
/* Bearbeitungsmodus: Header bleibt fuer Griff und Menue vorhanden */
.app .widgetCard-search.widgetCardEditMode .widgetHeader {
height: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
padding: 0 34px 0 32px !important;
margin: 0 !important;
overflow: visible !important;
display: flex !important;
align-items: center !important;
}
/* Menue im Suchwidget darf nie verschwinden */
.app .widgetCard-search .widgetMenu {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
z-index: 1500 !important;
}
.app .widgetCard-search .widgetMenuButton {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.app .widgetCard-search.widgetCardMenuOpen {
z-index: 1600 !important;
overflow: visible !important;
}
.app .widgetCard-search.widgetCardMenuOpen .widgetDropdown {
z-index: 1700 !important;
max-height: min(260px, 70vh) !important;
overflow-y: auto !important;
}
/* Normalmodus: Suchzeile in kompletter Widgetflaeche zentrieren */
.app .widgetCard-search:not(.widgetCardEditMode) .widgetContent {
position: absolute !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 8px !important;
overflow: visible !important;
}
/* Bearbeitungsmodus: unterhalb Header zentrieren, Resize-Ecke freihalten */
.app .widgetCard-search.widgetCardEditMode .widgetContent {
position: absolute !important;
inset: 26px 0 0 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 6px 10px 12px 8px !important;
overflow: visible !important;
}
/* Suchformular mittig, einzeilig, mit Luft zur Resize-Ecke */
.app .widgetCard-search .searchWidgetForm {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
display: grid !important;
grid-template-columns: minmax(92px, 140px) minmax(0, 1fr) 38px !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
margin: 0 !important;
padding: 0 !important;
}
/* Eingaben gleich hoch und kompakt */
.app .widgetCard-search .searchWidgetForm .select,
.app .widgetCard-search .searchWidgetForm .searchInput {
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
padding: 0 10px !important;
border-radius: 9px !important;
font-size: 12px !important;
line-height: 34px !important;
box-sizing: border-box !important;
}
/* Suchbutton: einheitlich, nicht in die Ecke gedrueckt */
.app .widgetCard-search .searchWidgetForm .button,
.app .widgetCard-search .searchWidgetForm button[type="submit"] {
width: 38px !important;
min-width: 38px !important;
max-width: 38px !important;
height: 38px !important;
min-height: 38px !important;
max-height: 38px !important;
position: relative !important;
display: inline-grid !important;
place-items: center !important;
align-self: center !important;
justify-self: center !important;
padding: 0 !important;
overflow: hidden !important;
color: transparent !important;
font-size: 0 !important;
line-height: 0 !important;
text-indent: -9999px !important;
border-radius: 10px !important;
}
/* Lupenkreis */
.app .widgetCard-search .searchWidgetForm .button::before,
.app .widgetCard-search .searchWidgetForm button[type="submit"]::before {
content: "" !important;
position: absolute !important;
left: 50% !important;
top: 50% !important;
width: 14px !important;
height: 14px !important;
border: 2px solid #fff !important;
border-radius: 999px !important;
background: transparent !important;
transform: translate(-58%, -58%) !important;
box-sizing: border-box !important;
}
/* Lupengriff */
.app .widgetCard-search .searchWidgetForm .button::after,
.app .widgetCard-search .searchWidgetForm button[type="submit"]::after {
content: "" !important;
position: absolute !important;
left: 50% !important;
top: 50% !important;
width: 9px !important;
height: 2px !important;
background: #fff !important;
border: 0 !important;
border-radius: 999px !important;
transform: translate(1px, 5px) rotate(45deg) !important;
box-sizing: border-box !important;
}
/* Schmale Widgets */
@media (max-width: 640px) {
.app .widgetCard-search .searchWidgetForm {
grid-template-columns: minmax(72px, 110px) minmax(0, 1fr) 38px !important;
}
}
/* FIX: Such-Widget-Menü wieder sichtbar machen.
Nicht-destruktiv: Widget-Chrome bleibt erhalten, nur Layering/Clipping wird korrigiert. */
.app .widgetCard-search {
overflow: visible !important;
}
/* Im Bearbeitungsmodus muss der Header als Menü-/Griff-Zone sichtbar bleiben */
.app .widgetCard-search.widgetCardEditMode .widgetHeader {
position: relative !important;
z-index: 50 !important;
height: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
display: flex !important;
align-items: center !important;
overflow: visible !important;
pointer-events: auto !important;
}
/* Menü explizit sichtbar und über dem Suchformular */
.app .widgetCard-search.widgetCardEditMode .widgetMenu {
display: block !important;
position: absolute !important;
top: 2px !important;
right: 6px !important;
z-index: 2000 !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
.app .widgetCard-search.widgetCardEditMode .widgetMenuButton {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
/* Dropdown über allem und scrollbar */
.app .widgetCard-search.widgetCardMenuOpen,
.app .widgetCard-search.widgetCardEditMode.widgetCardMenuOpen {
overflow: visible !important;
z-index: 2000 !important;
}
.app .widgetCard-search.widgetCardMenuOpen .widgetDropdown {
display: grid !important;
visibility: visible !important;
opacity: 1 !important;
z-index: 2100 !important;
max-height: min(260px, 70vh) !important;
overflow-y: auto !important;
pointer-events: auto !important;
}
/* Content darf das Menü nicht überdecken */
.app .widgetCard-search.widgetCardEditMode .widgetContent {
z-index: 1 !important;
pointer-events: auto !important;
}
/* FINAL FIX: Such-Widget behält einheitliche Widget-Chrome.
Ursache: vorherige Regeln haben den Header abhängig von widgetCardEditMode versteckt.
Diese Regeln nutzen den tatsächlich vorhandenen Menü-/Drag-Button als Edit-Indikator. */
/* Sobald Menü oder Griff existieren, ist das Widget im Bearbeitungszustand:
Header sichtbar halten, damit Menü/Griff nicht verschwinden. */
.app .widgetCard-search:has(.widgetMenuButton) .widgetHeader,
.app .widgetCard-search:has(.widgetDragHandle) .widgetHeader {
display: flex !important;
position: relative !important;
z-index: 3000 !important;
height: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
padding: 0 34px 0 32px !important;
margin: 0 !important;
align-items: center !important;
overflow: visible !important;
pointer-events: auto !important;
border-bottom: 0 !important;
}
/* Menü im Suchwidget sichtbar und klickbar halten */
.app .widgetCard-search:has(.widgetMenuButton) .widgetMenu {
display: block !important;
position: absolute !important;
top: 2px !important;
right: 6px !important;
z-index: 3100 !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
.app .widgetCard-search:has(.widgetMenuButton) .widgetMenuButton {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
/* Griff links sichtbar halten */
.app .widgetCard-search:has(.widgetDragHandle) .widgetDragHandle {
display: grid !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
z-index: 3100 !important;
}
/* Im Bearbeitungszustand Content unter Header setzen und Resize-Ecke freihalten */
.app .widgetCard-search:has(.widgetMenuButton) .widgetContent,
.app .widgetCard-search:has(.widgetDragHandle) .widgetContent {
position: absolute !important;
inset: 26px 0 0 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 5px 22px 18px 8px !important;
overflow: visible !important;
z-index: 1 !important;
pointer-events: auto !important;
}
/* Widget selbst darf Menü/Dropdown nicht clippen */
.app .widgetCard-search,
.app .widgetCard-search.widgetCardMenuOpen {
overflow: visible !important;
}
/* Dropdown über anderen Widgets und scrollbar */
.app .widgetCard-search.widgetCardMenuOpen .widgetDropdown,
.app .widgetCard-search:has(.widgetMenuButton) .widgetDropdown {
display: grid !important;
visibility: visible !important;
opacity: 1 !important;
z-index: 3200 !important;
max-height: min(260px, 70vh) !important;
overflow-y: auto !important;
pointer-events: auto !important;
}
/* Suchzeile bleibt einheitlich und ragt nicht in die Resize-Ecke */
.app .widgetCard-search:has(.widgetMenuButton) .searchWidgetForm,
.app .widgetCard-search:has(.widgetDragHandle) .searchWidgetForm {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
align-items: center !important;
margin: 0 !important;
}
/* FINAL FIX: Such-Widget nutzt dieselbe Transparenzlogik wie alle Widgets.
Nicht-destruktiv: Chrome, Menü, Drag, Resize und Suchfunktion bleiben erhalten. */
.app .widgetCard-search {
background: transparent !important;
border-color: var(--border) !important;
box-shadow: var(--shadow) !important;
isolation: isolate !important;
}
/* Die normale Widget-Bubble wieder aktivieren.
Wichtig: Transparenz kommt über --widget-opacity wie bei allen anderen Widgets. */
.app .widgetCard-search::before {
content: "" !important;
display: block !important;
position: absolute !important;
inset: 0 !important;
z-index: -1 !important;
pointer-events: none !important;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--surface-strong) 92%, transparent),
color-mix(in srgb, var(--surface) 92%, transparent)
) !important;
border-radius: inherit !important;
opacity: var(--widget-opacity, 1) !important;
}
/* Inhalt bleibt vollständig sichtbar und unabhängig von der Bubble-Transparenz */
.app .widgetCard-search > * {
position: relative !important;
z-index: 1 !important;
opacity: 1 !important;
}
+924
View File
@@ -0,0 +1,924 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
type Settings = {
id: string;
userId: string;
darkMode: boolean;
calendarIcsUrl: string | null;
calendarMaxEvents: number;
calendarLookaheadDays: number;
dashboardTitle: string;
dashboardSubtitle: string | null;
logoUrl: string | null;
faviconUrl: string | null;
backgroundImageUrl: string | null;
backgroundImageOpacity: number;
primaryColor: string;
secondaryColor: string;
customCss: string;
};
type CalendarSourceType = "ICS" | "EXCHANGE_EWS";
type CalendarSource = {
id: string;
userId: string;
type: CalendarSourceType;
name: string;
color: string;
nextEventsCount: number;
icsUrl: string | null;
exchangeEwsUrl: string | null;
exchangeMailbox: string | null;
exchangeUsername: string | null;
exchangeDomain: string | null;
passwordConfigured: boolean;
};
type CalendarSourceDraft = {
sourceId: string | null;
type: CalendarSourceType;
name: string;
color: string;
nextEventsCount: number;
icsUrl: string;
exchangeEwsUrl: string;
exchangeMailbox: string;
exchangeUsername: string;
exchangeDomain: string;
exchangePassword: string;
};
function getEmptyCalendarSourceDraft(): CalendarSourceDraft {
return {
sourceId: null,
type: "ICS",
name: "",
color: "#2563eb",
nextEventsCount: 3,
icsUrl: "",
exchangeEwsUrl: "",
exchangeMailbox: "",
exchangeUsername: "",
exchangeDomain: "",
exchangePassword: ""
};
}
function sourceToDraft(source: CalendarSource): CalendarSourceDraft {
return {
sourceId: source.id,
type: source.type,
name: source.name,
color: source.color,
nextEventsCount: source.nextEventsCount ?? 3,
icsUrl: source.icsUrl ?? "",
exchangeEwsUrl: source.exchangeEwsUrl ?? "",
exchangeMailbox: source.exchangeMailbox ?? "",
exchangeUsername: source.exchangeUsername ?? "",
exchangeDomain: source.exchangeDomain ?? "",
exchangePassword: ""
};
}
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;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [dashboardTitle, setDashboardTitle] = useState("");
const [dashboardSubtitle, setDashboardSubtitle] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [faviconUrl, setFaviconUrl] = useState("");
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
const [backgroundImageOpacity, setBackgroundImageOpacity] = useState(0);
const [calendarMaxEvents, setCalendarMaxEvents] = useState(8);
const [calendarLookaheadDays, setCalendarLookaheadDays] = useState(60);
const [calendarSources, setCalendarSources] = useState<CalendarSource[]>([]);
const [calendarSourceDraft, setCalendarSourceDraft] = useState<CalendarSourceDraft>(() => getEmptyCalendarSourceDraft());
const [primaryColor, setPrimaryColor] = useState("#2563eb");
const [secondaryColor, setSecondaryColor] = useState("#dbeafe");
const [customCss, setCustomCss] = useState("");
const [backgroundUploadFile, setBackgroundUploadFile] = useState<File | null>(null);
const [logoUploadFile, setLogoUploadFile] = useState<File | null>(null);
const [faviconUploadFile, setFaviconUploadFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingBackground, setUploadingBackground] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadSettings();
}, []);
function applySettings(nextSettings: Settings) {
setSettings(nextSettings);
setDashboardTitle(nextSettings.dashboardTitle ?? "Personal Dashboard");
setDashboardSubtitle(nextSettings.dashboardSubtitle ?? "");
setLogoUrl(nextSettings.logoUrl ?? "/logo.svg");
setFaviconUrl(nextSettings.faviconUrl ?? "/favicon.ico");
setBackgroundImageUrl(nextSettings.backgroundImageUrl ?? "");
setBackgroundImageOpacity(nextSettings.backgroundImageOpacity ?? 0);
setCalendarMaxEvents(nextSettings.calendarMaxEvents ?? 8);
setCalendarLookaheadDays(nextSettings.calendarLookaheadDays ?? 60);
setPrimaryColor(nextSettings.primaryColor ?? "#2563eb");
setSecondaryColor(nextSettings.secondaryColor ?? "#dbeafe");
setCustomCss(nextSettings.customCss ?? "");
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("personal-dashboard-settings-updated", {
detail: {
dashboardTitle: nextSettings.dashboardTitle,
faviconUrl: nextSettings.faviconUrl ?? "/favicon.ico"
}
})
);
}
}
async function loadCalendarSources() {
const response = await fetch("/api/calendar/source", {
cache: "no-store"
});
const data = await parseJsonResponse<{ sources: CalendarSource[] }>(response);
setCalendarSources(data.sources);
}
async function loadSettings() {
setLoading(true);
setError(null);
try {
const [settingsResponse] = await Promise.all([
fetch("/api/settings", {
cache: "no-store"
})
]);
const settingsData = await parseJsonResponse<{ settings: Settings }>(settingsResponse);
applySettings(settingsData.settings);
await loadCalendarSources();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Einstellungen konnten nicht geladen werden.");
} finally {
setLoading(false);
}
}
async function saveSettings(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
dashboardTitle,
dashboardSubtitle,
logoUrl,
faviconUrl,
backgroundImageUrl,
backgroundImageOpacity,
calendarIcsUrl: null,
calendarMaxEvents,
calendarLookaheadDays,
primaryColor,
secondaryColor,
customCss
})
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setMessage("Einstellungen gespeichert.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Einstellungen konnten nicht gespeichert werden.");
} finally {
setSaving(false);
}
}
async function saveCalendarSource() {
setSaving(true);
setError(null);
setMessage(null);
try {
const isUpdate = Boolean(calendarSourceDraft.sourceId);
const response = await fetch("/api/calendar/source", {
method: isUpdate ? "PUT" : "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sourceId: calendarSourceDraft.sourceId,
type: calendarSourceDraft.type,
name: calendarSourceDraft.name,
color: calendarSourceDraft.color,
nextEventsCount: calendarSourceDraft.nextEventsCount,
icsUrl: calendarSourceDraft.icsUrl,
exchangeEwsUrl: calendarSourceDraft.exchangeEwsUrl,
exchangeMailbox: calendarSourceDraft.exchangeMailbox,
exchangeUsername: calendarSourceDraft.exchangeUsername,
exchangeDomain: calendarSourceDraft.exchangeDomain,
exchangePassword: calendarSourceDraft.exchangePassword
})
});
await parseJsonResponse<{ source: CalendarSource }>(response);
await loadCalendarSources();
setCalendarSourceDraft(getEmptyCalendarSourceDraft());
setMessage(isUpdate ? "Kalenderquelle gespeichert." : "Kalenderquelle hinzugefügt.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Kalenderquelle konnte nicht gespeichert werden.");
} finally {
setSaving(false);
}
}
async function deleteCalendarSource(sourceId: string) {
const confirmed = window.confirm("Kalenderquelle wirklich löschen? Diese Quelle wird aus allen Kalender-Widgets entfernt.");
if (!confirmed) {
return;
}
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch(`/api/calendar/source?sourceId=${encodeURIComponent(sourceId)}`, {
method: "DELETE"
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? "Kalenderquelle konnte nicht gelöscht werden.");
}
await loadCalendarSources();
if (calendarSourceDraft.sourceId === sourceId) {
setCalendarSourceDraft(getEmptyCalendarSourceDraft());
}
setMessage("Kalenderquelle gelöscht.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Kalenderquelle konnte nicht gelöscht werden.");
} finally {
setSaving(false);
}
}
async function uploadLogo() {
if (!logoUploadFile) {
setError("Bitte zuerst eine Logo-Datei auswählen.");
return;
}
setUploadingLogo(true);
setError(null);
setMessage(null);
try {
const formData = new FormData();
formData.append("file", logoUploadFile);
const response = await fetch("/api/settings/logo", {
method: "POST",
body: formData
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setLogoUploadFile(null);
setMessage("Logo hochgeladen.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Logo konnte nicht hochgeladen werden.");
} finally {
setUploadingLogo(false);
}
}
async function resetLogo() {
setUploadingLogo(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/settings/logo", {
method: "DELETE"
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setLogoUploadFile(null);
setMessage("Logo zurückgesetzt.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Logo konnte nicht zurückgesetzt werden.");
} finally {
setUploadingLogo(false);
}
}
async function uploadFavicon() {
if (!faviconUploadFile) {
setError("Bitte zuerst eine Favicon-Datei auswählen.");
return;
}
setUploadingFavicon(true);
setError(null);
setMessage(null);
try {
const formData = new FormData();
formData.append("file", faviconUploadFile);
const response = await fetch("/api/settings/favicon", {
method: "POST",
body: formData
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setFaviconUploadFile(null);
setMessage("Favicon hochgeladen.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Favicon konnte nicht hochgeladen werden.");
} finally {
setUploadingFavicon(false);
}
}
async function resetFavicon() {
setUploadingFavicon(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/settings/favicon", {
method: "DELETE"
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setFaviconUploadFile(null);
setMessage("Favicon zurückgesetzt.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Favicon konnte nicht zurückgesetzt werden.");
} finally {
setUploadingFavicon(false);
}
}
async function uploadBackground() {
if (!backgroundUploadFile) {
setError("Bitte zuerst eine Hintergrund-Datei auswählen.");
return;
}
setUploadingBackground(true);
setError(null);
setMessage(null);
try {
const formData = new FormData();
formData.append("file", backgroundUploadFile);
const response = await fetch("/api/settings/background", {
method: "POST",
body: formData
});
const data = await parseJsonResponse<{ settings: Settings }>(response);
applySettings(data.settings);
setBackgroundUploadFile(null);
setMessage("Hintergrund hochgeladen.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Hintergrund konnte nicht hochgeladen werden.");
} finally {
setUploadingBackground(false);
}
}
function clearBackground() {
setBackgroundImageUrl("");
setBackgroundImageOpacity(0);
}
if (loading) {
return (
<main className="app">
<section className="adminShell">
<div className="adminPanel">
<h1>Einstellungen</h1>
<p className="muted">Lade...</p>
</div>
</section>
</main>
);
}
if (!settings) {
return (
<main className="app">
<section className="adminShell">
<div className="adminPanel">
<h1>Einstellungen</h1>
<p className="errorText">{error ?? "Einstellungen konnten nicht geladen werden."}</p>
<a className="button adminInlineButton" href="/">
Zurück
</a>
</div>
</section>
</main>
);
}
return (
<main className={settings.darkMode ? "app appDark" : "app"}>
<header className="adminTopBar">
<div className="brandBlock">
<div className="brandLogoFrame">
<img className="brandLogo" src={logoUrl || "/logo.svg"} alt="Dashboard Logo" />
</div>
<div className="brandText">
<div className="title">Meine Einstellungen</div>
<div className="subtitle">Darstellung, Kalender, Hintergrund und Integrationen</div>
</div>
</div>
<div className="adminTopActions">
<a className="button buttonSecondary" href="/">
Zurück zum Dashboard
</a>
</div>
</header>
<section className="adminShell">
<form className="adminPanel adminForm" onSubmit={saveSettings}>
<h1>Einstellungen</h1>
{error ? <p className="errorText">{error}</p> : null}
{message ? <p className="successText">{message}</p> : null}
<label className="fieldLabel">
Dashboard-Titel
<input className="input" value={dashboardTitle} onChange={(event) => setDashboardTitle(event.target.value)} maxLength={120} />
</label>
<label className="fieldLabel">
Untertitel
<input
className="input"
value={dashboardSubtitle}
onChange={(event) => setDashboardSubtitle(event.target.value)}
maxLength={160}
placeholder="Optional"
/>
</label>
<section className="settingsSubPanel">
<h2>Logo</h2>
<div className="logoSettingsRow">
<div className="logoSettingsPreview">
<img src={logoUrl || "/logo.svg"} alt="Logo-Vorschau" />
</div>
<div className="logoSettingsFields">
<label className="fieldLabel">
Logo-URL optional
<input
className="input"
value={logoUrl}
onChange={(event) => setLogoUrl(event.target.value)}
placeholder="/logo.svg, /api/uploads/... oder https://..."
/>
</label>
<label className="fieldLabel">
Logo hochladen
<input
className="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
onChange={(event) => setLogoUploadFile(event.target.files?.[0] ?? null)}
/>
</label>
<div className="settingsButtonRow">
<button type="button" className="button buttonSecondary" onClick={() => void uploadLogo()} disabled={uploadingLogo}>
{uploadingLogo ? "Lade hoch..." : "Logo hochladen"}
</button>
<button type="button" className="button buttonSecondary" onClick={() => void resetLogo()} disabled={uploadingLogo}>
Standardlogo verwenden
</button>
</div>
</div>
</div>
</section>
<section className="settingsSubPanel">
<h2>Favicon</h2>
<div className="logoSettingsRow">
<div className="logoSettingsPreview">
<img src={faviconUrl || "/favicon.ico"} alt="Favicon-Vorschau" />
</div>
<div className="logoSettingsFields">
<label className="fieldLabel">
Favicon-URL optional
<input
className="input"
value={faviconUrl}
onChange={(event) => setFaviconUrl(event.target.value)}
placeholder="/favicon.ico, /api/uploads/... oder https://..."
/>
</label>
<label className="fieldLabel">
Favicon hochladen
<input
className="fileInput"
type="file"
accept="image/x-icon,image/vnd.microsoft.icon,image/png,image/svg+xml"
onChange={(event) => setFaviconUploadFile(event.target.files?.[0] ?? null)}
/>
</label>
<div className="settingsButtonRow">
<button type="button" className="button buttonSecondary" onClick={() => void uploadFavicon()} disabled={uploadingFavicon}>
{uploadingFavicon ? "Lade hoch..." : "Favicon hochladen"}
</button>
<button type="button" className="button buttonSecondary" onClick={() => void resetFavicon()} disabled={uploadingFavicon}>
Standard-Favicon verwenden
</button>
</div>
</div>
</div>
</section>
<div className="themeColorGrid">
<label className="fieldLabel">
Primärfarbe
<input className="colorInput" type="color" value={primaryColor} onChange={(event) => setPrimaryColor(event.target.value)} />
</label>
<label className="fieldLabel">
Sekundärfarbe
<input className="colorInput" type="color" value={secondaryColor} onChange={(event) => setSecondaryColor(event.target.value)} />
</label>
</div>
<section className="settingsSubPanel">
<h2>Hintergrund</h2>
<label className="fieldLabel">
Hintergrund-URL
<input
className="input"
value={backgroundImageUrl}
onChange={(event) => setBackgroundImageUrl(event.target.value)}
placeholder="https://... oder /uploads/..."
/>
</label>
<label className="fieldLabel">
Sichtbarkeit des Hintergrunds: {backgroundImageOpacity} %
<input
className="rangeInput"
type="range"
min="0"
max="100"
step="1"
value={backgroundImageOpacity}
onChange={(event) => setBackgroundImageOpacity(Number(event.target.value))}
/>
</label>
<div className="backgroundPreviewBox">
{backgroundImageUrl ? (
<img className="backgroundPreviewImage" src={backgroundImageUrl} alt="Hintergrund-Vorschau" />
) : (
<p className="muted">Kein Hintergrund gesetzt.</p>
)}
{backgroundImageUrl ? (
<div
className="backgroundPreviewOverlay"
style={{
opacity: 1 - backgroundImageOpacity / 100
}}
/>
) : null}
</div>
<label className="fieldLabel">
Hintergrund hochladen
<input
className="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={(event) => setBackgroundUploadFile(event.target.files?.[0] ?? null)}
/>
</label>
<div className="settingsButtonRow">
<button type="button" className="button buttonSecondary" onClick={() => void uploadBackground()} disabled={uploadingBackground}>
{uploadingBackground ? "Lade hoch..." : "Hintergrund hochladen"}
</button>
<button type="button" className="button buttonSecondary" onClick={clearBackground}>
Hintergrund entfernen
</button>
</div>
</section>
<section className="settingsSubPanel">
<h2>Kalenderquellen</h2>
{calendarSources.length === 0 ? <p className="muted">Noch keine Kalenderquellen angelegt.</p> : null}
<div className="calendarSourceList">
{calendarSources.map((source) => (
<div className="calendarSourceListItem" key={source.id}>
<span className="calendarSourceColorDot" style={{ background: source.color }} />
<div>
<strong>{source.name}</strong>
<p className="muted">
{source.type === "EXCHANGE_EWS" ? "Exchange EWS" : "ICS"}
{source.exchangeUsername ? ` · ${source.exchangeUsername}` : ""}
{source.icsUrl ? ` · ${source.icsUrl}` : ""}
</p>
</div>
<div className="settingsButtonRow">
<button type="button" className="button buttonSecondary" onClick={() => setCalendarSourceDraft(sourceToDraft(source))}>
Bearbeiten
</button>
<button type="button" className="button buttonSecondary" onClick={() => void deleteCalendarSource(source.id)}>
Löschen
</button>
</div>
</div>
))}
</div>
<div className="calendarSourceEditor">
<h3>{calendarSourceDraft.sourceId ? "Kalenderquelle bearbeiten" : "Kalenderquelle hinzufügen"}</h3>
<label className="fieldLabel">
Typ
<select
className="select"
value={calendarSourceDraft.type}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
type: event.target.value === "EXCHANGE_EWS" ? "EXCHANGE_EWS" : "ICS"
}))
}
>
<option value="ICS">ICS</option>
<option value="EXCHANGE_EWS">Exchange On-Prem EWS</option>
</select>
</label>
<label className="fieldLabel">
Label
<input
className="input"
value={calendarSourceDraft.name}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
name: event.target.value
}))
}
placeholder="z.B. Arbeit, Privat, Abfuhr"
/>
</label>
<label className="fieldLabel">
Farbe
<input
className="colorInput"
type="color"
value={calendarSourceDraft.color}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
color: event.target.value
}))
}
/>
</label>
{calendarSourceDraft.type === "ICS" ? (
<label className="fieldLabel">
ICS-URL
<input
className="input"
value={calendarSourceDraft.icsUrl}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
icsUrl: event.target.value
}))
}
placeholder="https://...ics"
/>
</label>
) : null}
{calendarSourceDraft.type === "EXCHANGE_EWS" ? (
<>
<label className="fieldLabel">
EWS-URL
<input
className="input"
value={calendarSourceDraft.exchangeEwsUrl}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
exchangeEwsUrl: event.target.value
}))
}
placeholder="https://mail.example.local/EWS/Exchange.asmx"
/>
</label>
<label className="fieldLabel">
Benutzername
<input
className="input"
value={calendarSourceDraft.exchangeUsername}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
exchangeUsername: event.target.value
}))
}
/>
</label>
<label className="fieldLabel">
Domain optional
<input
className="input"
value={calendarSourceDraft.exchangeDomain}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
exchangeDomain: event.target.value
}))
}
/>
</label>
<label className="fieldLabel">
Mailbox optional
<input
className="input"
value={calendarSourceDraft.exchangeMailbox}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
exchangeMailbox: event.target.value
}))
}
/>
</label>
<label className="fieldLabel">
Passwort {calendarSourceDraft.sourceId ? "(leer lassen = unverändert)" : ""}
<input
className="input"
type="password"
value={calendarSourceDraft.exchangePassword}
onChange={(event) =>
setCalendarSourceDraft((current) => ({
...current,
exchangePassword: event.target.value
}))
}
autoComplete="new-password"
/>
</label>
</>
) : null}
<div className="settingsButtonRow">
<button type="button" className="button buttonSecondary" onClick={() => void saveCalendarSource()} disabled={saving}>
{calendarSourceDraft.sourceId ? "Kalenderquelle speichern" : "Kalenderquelle hinzufügen"}
</button>
<button type="button" className="button buttonSecondary" onClick={() => setCalendarSourceDraft(getEmptyCalendarSourceDraft())}>
Zurücksetzen
</button>
</div>
</div>
</section>
<section className="settingsSubPanel">
<h2>Kalenderanzeige</h2>
<div className="themeColorGrid">
<label className="fieldLabel">
Maximale Termine pro Quelle
<input
className="input"
type="number"
min="1"
max="50"
value={calendarMaxEvents}
onChange={(event) => setCalendarMaxEvents(Number(event.target.value))}
/>
</label>
<label className="fieldLabel">
Vorschau in Tagen
<input
className="input"
type="number"
min="1"
max="365"
value={calendarLookaheadDays}
onChange={(event) => setCalendarLookaheadDays(Number(event.target.value))}
/>
</label>
</div>
</section>
<section className="settingsSubPanel">
<h2>Eigenes CSS</h2>
<label className="fieldLabel">
Custom CSS
<textarea
className="input customCssTextarea"
value={customCss}
onChange={(event) => setCustomCss(event.target.value)}
placeholder={".widgetCard { backdrop-filter: blur(12px); }"}
spellCheck={false}
/>
</label>
<p className="muted">Dieses CSS wird nur für dein Dashboard angewandt. Bei fehlerhaftem CSS einfach hier wieder löschen.</p>
</section>
<button type="submit" className="button adminInlineButton" disabled={saving}>
{saving ? "Speichere..." : "Speichern"}
</button>
</form>
</section>
</main>
);
}
+162
View File
@@ -0,0 +1,162 @@
.editToolbar .button:first-child,
.editToolbar button:first-child {
display: inline-flex !important;
}
.editToolbar button,
.editToolbar .button {
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
.editToolbar .topEditModeButton,
.editToolbar button[aria-label="Bearbeitungsmodus aktivieren"],
.editToolbar button[aria-label="Bearbeitungsmodus deaktivieren"] {
display: none !important;
}
.topBar {
position: relative;
z-index: 5000;
}
.topBar .profileMenu {
position: relative;
z-index: 5400;
}
.topBar .profileDropdown {
z-index: 5500;
}
.topBar .topEditModeButton {
position: absolute;
top: 50%;
right: 78px;
z-index: 5350;
transform: translateY(-50%);
}
.topEditModeButton {
width: 42px;
height: 42px;
display: inline-grid;
place-items: center;
padding: 0;
color: var(--text);
background: var(--surface-strong);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
box-shadow: var(--shadow);
font-size: 0;
}
.topEditModeButton:hover {
border-color: var(--accent);
}
.topEditModeButtonActive {
color: var(--accent-text);
background: var(--accent);
border-color: var(--accent);
}
.lockIcon {
position: relative;
width: 22px;
height: 24px;
display: block;
color: currentColor;
}
.lockIconBody {
position: absolute;
left: 3px;
bottom: 2px;
width: 16px;
height: 13px;
background: transparent;
border: 2px solid currentColor;
border-radius: 4px;
}
.lockIconBody::before {
content: "";
position: absolute;
left: 50%;
top: 4px;
width: 3px;
height: 3px;
background: currentColor;
border-radius: 999px;
transform: translateX(-50%);
}
.lockIconBody::after {
content: "";
position: absolute;
left: 50%;
top: 7px;
width: 2px;
height: 4px;
background: currentColor;
border-radius: 999px;
transform: translateX(-50%);
}
.lockIconShackle {
position: absolute;
width: 12px;
height: 11px;
background: transparent;
border: 2px solid currentColor;
border-bottom: 0;
border-radius: 10px 10px 0 0;
}
.lockIconClosed .lockIconShackle {
left: 5px;
top: 1px;
transform: none;
}
.lockIconOpen .lockIconShackle {
left: 9px;
top: 1px;
transform: rotate(35deg);
transform-origin: left bottom;
}
.dashboardWorkspace,
.widgetGridShell,
.emptyDashboard,
.react-grid-layout,
.react-grid-item {
z-index: auto;
}
.react-grid-item.react-draggable-dragging,
.react-grid-item.resizing {
z-index: 300;
}
.gridItemMenuOpen {
z-index: 400 !important;
}
@media (max-width: 760px) {
.topBar .topEditModeButton {
right: 72px;
}
.topEditModeButton {
width: 38px;
height: 38px;
}
.lockIcon {
transform: scale(0.9);
}
}
+77
View File
@@ -0,0 +1,77 @@
:root {
--accent-text-fallback: #ffffff;
}
.app {
--accent-text: var(--accent-text, var(--accent-text-fallback));
}
.button,
.adminInlineButton,
.favoriteAddButton,
.searchWidgetForm .button {
border-color: color-mix(in srgb, var(--accent) 70%, var(--border) 30%);
}
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton),
.adminInlineButton,
.favoriteAddButton,
.searchWidgetForm .button {
color: var(--accent-text, #ffffff);
background: var(--accent);
}
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton):hover,
.adminInlineButton:hover,
.favoriteAddButton:hover,
.searchWidgetForm .button:hover {
border-color: var(--accent);
filter: brightness(1.05);
}
.buttonSecondary,
.profileDropdown .button,
.widgetMenuButton,
.widgetDragHandle {
color: var(--text);
background: color-mix(in srgb, var(--accent-soft) 42%, var(--surface-strong) 58%);
border-color: color-mix(in srgb, var(--accent) 28%, var(--border) 72%);
}
.buttonSecondary:hover,
.profileDropdown .button:hover,
.widgetMenuButton:hover,
.widgetDragHandle:hover {
border-color: var(--accent);
}
.input:focus,
.select:focus,
textarea:focus,
.fileInput:focus,
.colorInput:focus {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-color: var(--accent);
}
.topEditModeButtonActive {
color: var(--accent-text, #ffffff);
background: var(--accent);
border-color: var(--accent);
}
.favoriteTile:hover,
.widgetCard:hover {
border-color: color-mix(in srgb, var(--accent) 65%, var(--border) 35%);
}
.favoriteIconPlaceholder {
color: var(--accent-text, #ffffff);
background: var(--accent);
}
.equalsButton {
color: var(--accent-text, #ffffff) !important;
background: var(--accent) !important;
border-color: var(--accent) !important;
}
+505
View File
@@ -0,0 +1,505 @@
/* =========================================================
Widget Density Override
Muss ganz zuletzt importiert werden.
Ziel: wirklich schlankere Widgets, ohne Funktionen zu entfernen.
========================================================= */
/* Widget-Karte allgemein */
.app .widgetCard {
border-radius: 13px !important;
}
/* Header: flacher, Text sauber mittig */
.app .widgetCard .widgetHeader {
min-height: 24px !important;
height: 24px !important;
display: flex !important;
align-items: center !important;
gap: 5px !important;
padding: 3px 7px !important;
box-sizing: border-box !important;
}
.app .widgetCard.widgetCardEditMode .widgetHeader {
padding-left: 31px !important;
padding-right: 32px !important;
}
.app .widgetCard .widgetTitle {
min-height: 0 !important;
height: 18px !important;
display: flex !important;
align-items: center !important;
min-width: 0 !important;
}
.app .widgetCard .widgetTitle h2 {
margin: 0 !important;
padding: 0 !important;
font-size: calc(13px * var(--widget-font-scale, 1)) !important;
line-height: 18px !important;
height: 18px !important;
display: block !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.app .widgetCard .widgetTitleInput {
height: 22px !important;
min-height: 22px !important;
padding: 0 7px !important;
font-size: calc(13px * var(--widget-font-scale, 1)) !important;
line-height: 22px !important;
}
/* Content enger */
.app .widgetCard .widgetContent {
padding: 5px 7px 7px !important;
gap: 5px !important;
}
/* Uhr bleibt Sonderfall */
.app .widgetCard-clock .widgetHeader {
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetHeader {
height: 24px !important;
min-height: 24px !important;
}
.app .widgetCard-clock .widgetContent {
padding: 2px 5px 5px !important;
}
/* Griff und Menü kleiner */
.app .widgetDragHandle {
top: 2px !important;
left: 6px !important;
width: 20px !important;
height: 20px !important;
}
.app .widgetMenu {
top: 2px !important;
right: 6px !important;
}
.app .widgetMenuButton {
width: 22px !important;
min-width: 22px !important;
height: 22px !important;
min-height: 22px !important;
padding: 0 !important;
}
/* Notiz-Stiftbutton passend klein */
.app .noteHeaderEditButton {
width: 22px !important;
min-width: 22px !important;
height: 22px !important;
min-height: 22px !important;
padding: 0 !important;
}
/* Dropdown kleiner */
.app .widgetDropdown {
top: 25px !important;
min-width: 176px !important;
padding: 5px !important;
gap: 3px !important;
}
.app .widgetDropdownButton {
min-height: 27px !important;
padding: 0 8px !important;
font-size: 12px !important;
}
/* Standard-Controls in Widgets */
.app .widgetCard .button,
.app .widgetCard .buttonSecondary,
.app .widgetCard .input,
.app .widgetCard .select,
.app .widgetCard .searchInput {
min-height: 28px !important;
height: 28px !important;
font-size: 12px !important;
border-radius: 8px !important;
}
.app .widgetCard .button,
.app .widgetCard .buttonSecondary {
padding: 0 9px !important;
}
.app .widgetCard .input,
.app .widgetCard .select,
.app .widgetCard .searchInput {
padding: 0 8px !important;
}
/* Such-Widget: Suchbutton nur Lupe, kein Text */
.app .widgetCard-search .searchWidgetForm {
gap: 6px !important;
align-items: center !important;
}
.app .widgetCard-search .searchWidgetForm .button {
width: 38px !important;
min-width: 38px !important;
max-width: 38px !important;
height: 38px !important;
min-height: 38px !important;
padding: 0 !important;
overflow: hidden !important;
color: transparent !important;
font-size: 0 !important;
line-height: 0 !important;
position: relative !important;
}
.app .widgetCard-search .searchWidgetForm .button::before {
content: "⌕" !important;
display: grid !important;
place-items: center !important;
position: absolute !important;
inset: 0 !important;
color: #fff !important;
font-size: 25px !important;
line-height: 1 !important;
transform: none !important;
}
.app .widgetCard-search .searchWidgetForm .button::after {
content: none !important;
}
/* Favoriten kompakter */
.app .favoriteTileList,
.app .favoritesList {
gap: 5px !important;
}
.app .favoriteTile {
min-height: 31px !important;
padding: 5px 7px !important;
gap: 6px !important;
border-radius: 9px !important;
}
.app .favoriteIcon {
width: 24px !important;
height: 24px !important;
min-width: 24px !important;
}
.app .favoriteTitle {
font-size: calc(12px * var(--widget-font-scale, 1)) !important;
line-height: 1.1 !important;
}
/* Favoriten-Kachelmodus kompakter, aber lesbar */
.app .favoritesWidgetGridMode .favoriteTileList {
grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)) !important;
gap: 6px !important;
}
.app .favoritesWidgetGridMode .favoriteTile {
min-height: 60px !important;
padding: 5px 4px !important;
gap: 3px !important;
}
.app .favoritesWidgetGridMode .favoriteTile .favoriteIcon {
width: clamp(18px, 44cqi, 31px) !important;
height: clamp(18px, 44cqi, 31px) !important;
}
.app .favoritesWidgetGridMode .favoriteTile .favoriteTitle {
font-size: clamp(8px, 15cqi, 10px) !important;
line-height: 1.08 !important;
}
/* Notiz kompakter */
.app .noteMarkdownToolbar {
gap: 3px !important;
padding: 3px !important;
}
.app .noteMarkdownToolbar button,
.app .markdownToolbarButton {
min-width: 25px !important;
min-height: 24px !important;
height: 24px !important;
padding: 0 6px !important;
font-size: 11px !important;
}
.app .noteTextarea,
.app .singleNoteTextarea,
.app .noteMarkdownPreview {
padding: 7px !important;
font-size: 12px !important;
line-height: 1.33 !important;
border-radius: 9px !important;
}
/* Kalender kompakter */
.app .calendarHeader {
gap: 4px !important;
margin-bottom: 4px !important;
}
.app .calendarNavButton,
.app .calendarMonthButton {
min-height: 26px !important;
height: 26px !important;
padding: 0 7px !important;
font-size: 11px !important;
}
.app .calendarWeekdays {
gap: 3px !important;
margin-bottom: 3px !important;
}
.app .calendarGrid {
gap: 3px !important;
}
.app .calendarDay {
min-height: 24px !important;
padding: 2px !important;
border-radius: 7px !important;
}
.app .calendarDayNumber {
font-size: 10px !important;
}
.app .nextEventsBlock {
margin-top: 6px !important;
}
.app .eventItem {
padding: 6px !important;
border-radius: 9px !important;
}
/* Domainprüfung kompakter */
.app .domainCheckWidget {
gap: 7px !important;
}
.app .domainCheckField {
gap: 4px !important;
font-size: 11px !important;
}
.app .domainCheckInputRow {
gap: 5px !important;
}
.app .domainCheckResult {
padding: 8px !important;
gap: 6px !important;
border-radius: 10px !important;
}
/* FINAL FIX: Suchbutton nur eine zentrierte Lupe */
.app .widgetCard-search .searchWidgetForm .button,
.app .widgetCard-search .searchWidgetForm button[type="submit"] {
width: 38px !important;
min-width: 38px !important;
max-width: 38px !important;
height: 38px !important;
min-height: 38px !important;
max-height: 38px !important;
position: relative !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
overflow: hidden !important;
color: transparent !important;
font-size: 0 !important;
line-height: 0 !important;
text-indent: -9999px !important;
}
/* Kreis der Lupe */
.app .widgetCard-search .searchWidgetForm .button::before,
.app .widgetCard-search .searchWidgetForm button[type="submit"]::before {
content: "" !important;
position: absolute !important;
left: 50% !important;
top: 50% !important;
width: 14px !important;
height: 14px !important;
border: 2px solid #fff !important;
border-radius: 999px !important;
background: transparent !important;
transform: translate(-58%, -58%) !important;
box-sizing: border-box !important;
}
/* Griff der Lupe */
.app .widgetCard-search .searchWidgetForm .button::after,
.app .widgetCard-search .searchWidgetForm button[type="submit"]::after {
content: "" !important;
position: absolute !important;
left: 50% !important;
top: 50% !important;
width: 9px !important;
height: 2px !important;
background: #fff !important;
border: 0 !important;
border-radius: 999px !important;
transform: translate(1px, 5px) rotate(45deg) !important;
box-sizing: border-box !important;
}
/* FINAL FIX: Uhr-Widget immer sichtbar und sauber zentriert */
.app .widgetCard-clock {
min-height: 0 !important;
}
.app .widgetCard-clock .widgetHeader {
height: 0 !important;
min-height: 0 !important;
max-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetHeader {
height: 24px !important;
min-height: 24px !important;
max-height: 24px !important;
padding: 0 !important;
}
.app .widgetCard-clock .widgetContent {
height: 100% !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 4px 6px !important;
overflow: visible !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetContent {
height: calc(100% - 24px) !important;
padding-top: 2px !important;
}
/* CSS-Module-Klassen des ClockWidget robust sichtbar halten */
.app .widgetCard-clock .widgetContent > *,
.app .widgetCard-clock .widgetContent [class*="clock"],
.app .widgetCard-clock .widgetContent [class*="Clock"] {
max-width: 100% !important;
max-height: 100% !important;
opacity: 1 !important;
visibility: visible !important;
color: var(--text) !important;
overflow: visible !important;
}
/* Uhrzeit darf nicht abgeschnitten werden */
.app .widgetCard-clock .widgetContent [class*="time"],
.app .widgetCard-clock .widgetContent [class*="Time"] {
color: var(--text) !important;
line-height: 0.95 !important;
overflow: visible !important;
text-align: center !important;
}
/* Datum darunter lesbar */
.app .widgetCard-clock .widgetContent [class*="date"],
.app .widgetCard-clock .widgetContent [class*="Date"] {
color: var(--muted) !important;
line-height: 1.15 !important;
text-align: center !important;
}
/* FINAL CLOCK RESET: Uhr-Widget nicht durch globale Density-Regeln zerstören */
.app .widgetCard-clock .widgetContent {
height: 100% !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 0 !important;
overflow: hidden !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetContent {
height: calc(100% - 24px) !important;
padding: 0 !important;
}
.app .widgetCard-clock .widgetContent > * {
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
overflow: hidden !important;
}
/* FINAL CLOCK VISIBILITY FIX */
.app .widgetCard-clock .widgetContent {
height: 100% !important;
min-height: 0 !important;
display: grid !important;
place-items: center !important;
padding: 0 !important;
overflow: hidden !important;
}
.app .widgetCard-clock.widgetCardEditMode .widgetContent {
height: calc(100% - 24px) !important;
padding: 0 !important;
}
.app .widgetCard-clock .widgetContent > * {
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
display: grid !important;
opacity: 1 !important;
visibility: visible !important;
}
.app .widgetCard-clock .widgetContent time {
opacity: 1 !important;
visibility: visible !important;
color: var(--text) !important;
}
/* FIX: Widget-Menüs immer erreichbar und scrollbar */
.app .widgetCardMenuOpen {
overflow: visible !important;
z-index: 1200 !important;
}
.app .widgetCardMenuOpen .widgetHeader,
.app .widgetCardMenuOpen .widgetMenu {
overflow: visible !important;
z-index: 1300 !important;
}
.app .widgetCardMenuOpen .widgetDropdown {
z-index: 1500 !important;
max-height: min(260px, 70vh) !important;
overflow-y: auto !important;
overscroll-behavior: contain !important;
scrollbar-width: thin !important;
}
.react-grid-item:has(.widgetCardMenuOpen) {
overflow: visible !important;
z-index: 1200 !important;
}
+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>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { cookies } from "next/headers";
import { createHash, randomBytes, timingSafeEqual } from "crypto";
import { prisma } from "@/lib/prisma";
const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? "personal_dashboard_session";
export class UnauthorizedError extends Error {
constructor() {
super("Nicht angemeldet.");
this.name = "UnauthorizedError";
}
}
function getSessionCookieSecure(): boolean {
return process.env.SESSION_COOKIE_SECURE === "true";
}
function getSessionTtlDays(): number {
const parsed = Number.parseInt(process.env.SESSION_TTL_DAYS ?? "30", 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 365) {
return 30;
}
return parsed;
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createSessionToken(): string {
return randomBytes(32).toString("base64url");
}
export async function createSession(userId: string): Promise<string> {
const token = createSessionToken();
const tokenHash = hashToken(token);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + getSessionTtlDays());
await prisma.session.create({
data: {
tokenHash,
userId,
expiresAt
}
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: getSessionCookieSecure(),
path: "/",
expires: expiresAt
});
return token;
}
export async function destroyCurrentSession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (token) {
await prisma.session.deleteMany({
where: {
tokenHash: hashToken(token)
}
});
}
cookieStore.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: getSessionCookieSecure(),
path: "/",
expires: new Date(0)
});
}
export async function getCurrentUser() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return null;
}
const tokenHash = hashToken(token);
const session = await prisma.session.findUnique({
where: {
tokenHash
},
include: {
user: {
select: {
id: true,
email: true,
displayName: true,
role: true
}
}
}
});
if (!session || session.expiresAt <= new Date()) {
if (session) {
await prisma.session.delete({
where: {
id: session.id
}
});
}
return null;
}
const expected = Buffer.from(session.tokenHash, "hex");
const actual = Buffer.from(tokenHash, "hex");
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return null;
}
return session.user;
}
export async function requireCurrentUser() {
const user = await getCurrentUser();
if (!user) {
throw new UnauthorizedError();
}
return user;
}
+354
View File
@@ -0,0 +1,354 @@
import { XMLParser } from "fast-xml-parser";
import { decryptSecret } from "@/lib/secret-crypto";
export type DashboardCalendarEvent = {
id: string;
title: string;
start: string;
end: string | null;
location: string | null;
};
type CalendarSource = {
id: string;
type: string;
name: string;
color: string;
icsUrl: string | null;
exchangeEwsUrl: string | null;
exchangeMailbox: string | null;
exchangeUsername: string | null;
exchangeDomain: string | null;
exchangePasswordEnc: string | null;
exchangePasswordIv: string | null;
exchangePasswordTag: string | null;
};
type NtlmPostResult = {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
};
type ExchangeCredentials = {
username: string;
domain: string;
password: string;
};
function asArray<T>(value: T | T[] | undefined | null): T[] {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function normalizeText(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
function buildEventId(prefix: string, value: unknown, fallback: string): string {
const cleanValue = normalizeText(value).trim();
if (cleanValue) {
return `${prefix}:${cleanValue}`;
}
return `${prefix}:${fallback}`;
}
async function fetchText(url: string, options?: RequestInit): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 300)}`);
}
return text;
} finally {
clearTimeout(timeout);
}
}
function isEventInWindow(start: Date, end: Date | null, windowStart: Date, windowEnd: Date): boolean {
const effectiveEnd = end ?? start;
return start < windowEnd && effectiveEnd >= windowStart;
}
export async function fetchIcsEvents(
source: CalendarSource,
lookaheadDays: number,
maxEvents: number
): Promise<DashboardCalendarEvent[]> {
if (!source.icsUrl) {
return [];
}
const calendarText = await fetchText(source.icsUrl, {
headers: {
Accept: "text/calendar,*/*",
"User-Agent": "personal-dashboard/0.1.0"
}
});
const icalModule = await import("node-ical");
const ical: any = icalModule.default ?? icalModule;
const parsedCalendar = await ical.async.parseICS(calendarText);
const now = new Date();
const windowEnd = new Date(now);
windowEnd.setDate(now.getDate() + lookaheadDays);
return Object.values(parsedCalendar)
.filter((entry: any) => entry?.type === "VEVENT" && entry.start)
.map((entry: any, index) => {
const start = new Date(entry.start);
const end = entry.end ? new Date(entry.end) : null;
return {
id: buildEventId("ics", entry.uid, String(index)),
title: normalizeText(entry.summary).trim() || "Termin",
start: start.toISOString(),
end: end ? end.toISOString() : null,
location: normalizeText(entry.location).trim() || null
};
})
.filter((event) =>
isEventInWindow(new Date(event.start), event.end ? new Date(event.end) : null, now, windowEnd)
)
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, maxEvents);
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function buildEwsRequest(source: CalendarSource, lookaheadDays: number): string {
const now = new Date();
const windowEnd = new Date(now);
windowEnd.setDate(now.getDate() + lookaheadDays);
const mailboxBlock = source.exchangeMailbox
? `<t:Mailbox><t:EmailAddress>${escapeXml(source.exchangeMailbox)}</t:EmailAddress></t:Mailbox>`
: "";
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
<soap:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>Default</t:BaseShape>
</m:ItemShape>
<m:CalendarView StartDate="${now.toISOString()}" EndDate="${windowEnd.toISOString()}" />
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar">
${mailboxBlock}
</t:DistinguishedFolderId>
</m:ParentFolderIds>
</m:FindItem>
</soap:Body>
</soap:Envelope>`;
}
function getExchangePassword(source: CalendarSource): string {
if (!source.exchangePasswordEnc || !source.exchangePasswordIv || !source.exchangePasswordTag) {
throw new Error("Exchange-Passwort ist nicht konfiguriert.");
}
return decryptSecret(source.exchangePasswordEnc, source.exchangePasswordIv, source.exchangePasswordTag);
}
function parseExchangeCredentials(source: CalendarSource): ExchangeCredentials {
const rawUsername = source.exchangeUsername?.trim();
if (!rawUsername) {
throw new Error("Exchange-Benutzername fehlt.");
}
const configuredDomain = source.exchangeDomain?.trim() ?? "";
const password = getExchangePassword(source);
if (rawUsername.includes("\\") && !configuredDomain) {
const [domain, ...usernameParts] = rawUsername.split("\\");
const username = usernameParts.join("\\");
if (!domain || !username) {
throw new Error("Exchange-Benutzername ist ungültig.");
}
return {
username,
domain,
password
};
}
return {
username: rawUsername,
domain: configuredDomain,
password
};
}
function getHeaderValue(headers: Record<string, string | string[] | undefined>, name: string): string {
const value = headers[name.toLowerCase()] ?? headers[name];
if (Array.isArray(value)) {
return value.join(", ");
}
return value ?? "";
}
function postWithNtlm(options: {
url: string;
username: string;
password: string;
domain: string;
body: string;
headers: Record<string, string>;
}): Promise<NtlmPostResult> {
return new Promise((resolve, reject) => {
const httpntlm = require("httpntlm");
httpntlm.post(
{
url: options.url,
username: options.username,
password: options.password,
domain: options.domain,
workstation: "",
headers: options.headers,
body: options.body,
timeout: 15000
},
(error: unknown, response: NtlmPostResult) => {
if (error) {
reject(error);
return;
}
resolve(response);
}
);
});
}
function parseEwsEvents(xml: string, maxEvents: number): DashboardCalendarEvent[] {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
removeNSPrefix: true
});
const parsed = parser.parse(xml);
const body = parsed?.Envelope?.Body;
if (body?.Fault) {
throw new Error(normalizeText(body.Fault?.faultstring) || "Exchange EWS SOAP-Fehler.");
}
const responseMessage = body?.FindItemResponse?.ResponseMessages?.FindItemResponseMessage;
const responseClass = responseMessage?.["@_ResponseClass"];
if (responseClass && responseClass !== "Success") {
const messageText = normalizeText(responseMessage?.MessageText) || "Exchange EWS Anfrage fehlgeschlagen.";
throw new Error(messageText);
}
const calendarItems = asArray(responseMessage?.RootFolder?.Items?.CalendarItem);
return calendarItems
.map((item: any, index) => {
const itemId = item?.ItemId?.["@_Id"] ?? String(index);
const start = item?.Start ? new Date(item.Start) : null;
const end = item?.End ? new Date(item.End) : null;
if (!start || Number.isNaN(start.getTime())) {
return null;
}
return {
id: buildEventId("ews", itemId, String(index)),
title: normalizeText(item.Subject).trim() || "Termin",
start: start.toISOString(),
end: end && !Number.isNaN(end.getTime()) ? end.toISOString() : null,
location: normalizeText(item.Location).trim() || null
};
})
.filter((event): event is DashboardCalendarEvent => Boolean(event))
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, maxEvents);
}
export async function fetchExchangeEwsEvents(
source: CalendarSource,
lookaheadDays: number,
maxEvents: number
): Promise<DashboardCalendarEvent[]> {
if (!source.exchangeEwsUrl) {
return [];
}
const credentials = parseExchangeCredentials(source);
const response = await postWithNtlm({
url: source.exchangeEwsUrl,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
body: buildEwsRequest(source, lookaheadDays),
headers: {
"Content-Type": "text/xml; charset=utf-8",
Accept: "text/xml",
"User-Agent": "personal-dashboard/0.1.0"
}
});
const statusCode = response.statusCode;
const authenticateHeader = getHeaderValue(response.headers ?? {}, "www-authenticate");
if (statusCode === 401) {
throw new Error(
authenticateHeader
? `Exchange EWS Anmeldung fehlgeschlagen. Server meldet: ${authenticateHeader}`
: "Exchange EWS Anmeldung fehlgeschlagen."
);
}
if (statusCode < 200 || statusCode >= 300) {
throw new Error(`Exchange EWS HTTP ${statusCode}: ${response.body.slice(0, 300)}`);
}
return parseEwsEvents(response.body, maxEvents);
}
+40
View File
@@ -0,0 +1,40 @@
export type DashboardLayoutItem = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
export type DashboardGridWidget = {
id: string;
tabId?: string | null;
type: string;
title: string;
position: number;
x: number;
y: number;
w: number;
h: number;
opacity?: number;
fontSize?: number;
viewMode?: string;
};
export function sortLayoutForPosition(layout: readonly DashboardLayoutItem[]): DashboardLayoutItem[] {
return [...layout].sort((a, b) => {
if (a.y !== b.y) {
return a.y - b.y;
}
if (a.x !== b.x) {
return a.x - b.x;
}
return a.i.localeCompare(b.i);
});
}
+280
View File
@@ -0,0 +1,280 @@
import { lookup } from "dns/promises";
import { isIP } from "net";
type FetchHtmlResult = {
html: string;
finalUrl: string;
};
const FALLBACK_ICON_PATH = "/favicon.ico";
const HTML_FETCH_TIMEOUT_MS = 4000;
const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000;
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 192 && b === 0) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
function isPrivateIpv6(address: string): boolean {
const lowerAddress = address.toLowerCase();
return (
lowerAddress === "::" ||
lowerAddress === "::1" ||
lowerAddress.startsWith("fc") ||
lowerAddress.startsWith("fd") ||
lowerAddress.startsWith("fe80:") ||
lowerAddress.startsWith("::ffff:127.") ||
lowerAddress.startsWith("::ffff:10.") ||
lowerAddress.startsWith("::ffff:192.168.")
);
}
function isPrivateIpAddress(address: string): boolean {
const ipVersion = isIP(address);
if (ipVersion === 4) {
return isPrivateIpv4(address);
}
if (ipVersion === 6) {
return isPrivateIpv6(address);
}
return true;
}
function isBlockedHostname(hostname: string): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
return (
normalizedHostname === "localhost" ||
normalizedHostname.endsWith(".localhost") ||
normalizedHostname.endsWith(".local") ||
normalizedHostname === "0.0.0.0"
);
}
async function isPublicHttpUrl(url: URL): Promise<boolean> {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}
if (isBlockedHostname(url.hostname)) {
return false;
}
const directIpVersion = isIP(url.hostname);
if (directIpVersion !== 0) {
return !isPrivateIpAddress(url.hostname);
}
try {
const addresses = await lookup(url.hostname, {
all: true
});
if (addresses.length === 0) {
return false;
}
return addresses.every((address) => !isPrivateIpAddress(address.address));
} catch {
return false;
}
}
function getOriginFaviconUrl(pageUrl: string): string {
try {
const url = new URL(pageUrl);
return new URL(FALLBACK_ICON_PATH, url.origin).toString();
} catch {
return FALLBACK_ICON_PATH;
}
}
function parseAttributes(tag: string): Record<string, string> {
const attributes: Record<string, string> = {};
const attributeRegex = /([a-zA-Z_:.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
let match: RegExpExecArray | null;
while ((match = attributeRegex.exec(tag)) !== null) {
const key = match[1].toLowerCase();
const value = match[3] ?? match[4] ?? match[5] ?? "";
attributes[key] = value;
}
return attributes;
}
function toAbsoluteUrl(value: string | undefined, baseUrl: string): string | null {
if (!value) {
return null;
}
try {
const url = new URL(value, baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.toString();
} catch {
return null;
}
}
function extractIconFromHtml(html: string, baseUrl: string): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
const linkCandidates = linkTags
.map((tag) => parseAttributes(tag))
.map((attributes) => ({
rel: (attributes.rel ?? "").toLowerCase(),
href: toAbsoluteUrl(attributes.href, baseUrl)
}))
.filter((candidate) => candidate.href);
const appleTouchIcon = linkCandidates.find((candidate) => candidate.rel.includes("apple-touch-icon"))?.href;
if (appleTouchIcon) {
return appleTouchIcon;
}
const icon = linkCandidates.find((candidate) => candidate.rel.split(/\s+/).includes("icon"))?.href;
if (icon) {
return icon;
}
const shortcutIcon = linkCandidates.find((candidate) => candidate.rel.includes("shortcut icon"))?.href;
if (shortcutIcon) {
return shortcutIcon;
}
const imageSrc = linkCandidates.find((candidate) => candidate.rel.includes("image_src"))?.href;
if (imageSrc) {
return imageSrc;
}
const metaImage = metaTags
.map((tag) => parseAttributes(tag))
.map((attributes) => ({
property: (attributes.property ?? attributes.name ?? "").toLowerCase(),
content: toAbsoluteUrl(attributes.content, baseUrl)
}))
.find(
(candidate) =>
candidate.content &&
(candidate.property === "og:image" ||
candidate.property === "og:logo" ||
candidate.property === "twitter:image")
)?.content;
return metaImage ?? null;
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
let currentUrl = pageUrl;
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const publicUrl = await isPublicHttpUrl(currentUrl);
if (!publicUrl) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HTML_FETCH_TIMEOUT_MS);
try {
const response = await fetch(currentUrl.toString(), {
cache: "no-store",
redirect: "manual",
signal: controller.signal,
headers: {
Accept: "text/html,application/xhtml+xml,*/*",
"User-Agent": "personal-dashboard/0.1.0"
}
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
return null;
}
currentUrl = new URL(location, currentUrl);
continue;
}
if (!response.ok) {
return null;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("text/html")) {
return null;
}
const html = await response.text();
return {
html: html.slice(0, MAX_HTML_CHARS),
finalUrl: currentUrl.toString()
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
return null;
}
export async function discoverFavoriteIconUrl(pageUrl: string): Promise<string> {
const fallbackIconUrl = getOriginFaviconUrl(pageUrl);
try {
const url = new URL(pageUrl);
const htmlResult = await fetchHtml(url);
if (!htmlResult) {
return fallbackIconUrl;
}
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallbackIconUrl;
} catch {
return fallbackIconUrl;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+57
View File
@@ -0,0 +1,57 @@
import crypto from "crypto";
type EncryptedSecret = {
encrypted: string;
iv: string;
tag: string;
};
function getEncryptionKey(): Buffer {
const rawKey = process.env.CALENDAR_ENCRYPTION_KEY;
if (!rawKey) {
throw new Error("CALENDAR_ENCRYPTION_KEY fehlt.");
}
const base64Key = Buffer.from(rawKey, "base64");
if (base64Key.length === 32) {
return base64Key;
}
const hexKey = Buffer.from(rawKey, "hex");
if (hexKey.length === 32) {
return hexKey;
}
throw new Error("CALENDAR_ENCRYPTION_KEY muss 32 Byte Base64 oder 32 Byte Hex sein.");
}
export function encryptSecret(secret: string): EncryptedSecret {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
encrypted: encrypted.toString("base64"),
iv: iv.toString("base64"),
tag: tag.toString("base64")
};
}
export function decryptSecret(encrypted: string, iv: string, tag: string): string {
const key = getEncryptionKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
decipher.setAuthTag(Buffer.from(tag, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encrypted, "base64")),
decipher.final()
]);
return decrypted.toString("utf8");
}
+228
View File
@@ -0,0 +1,228 @@
export function normalizeEmail(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const email = value.trim().toLowerCase();
if (!email.includes("@") || email.length > 254) {
return null;
}
return email;
}
export function normalizePassword(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
if (value.length < 10 || value.length > 128) {
return null;
}
return value;
}
export function normalizeDisplayName(value: unknown): string | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value !== "string") {
return null;
}
const displayName = value.trim();
if (displayName.length < 1 || displayName.length > 80) {
return null;
}
return displayName;
}
export function normalizeUserRole(value: unknown): "ADMIN" | "USER" | null {
if (value === "ADMIN" || value === "USER") {
return value;
}
return null;
}
export function normalizeDashboardTitle(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const title = value.trim();
if (title.length < 1 || title.length > 80) {
return null;
}
return title;
}
export function normalizeOptionalDashboardSubtitle(value: unknown): string | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value !== "string") {
return null;
}
const subtitle = value.trim();
if (subtitle.length > 120) {
return null;
}
return subtitle.length > 0 ? subtitle : null;
}
export function normalizeThemeColor(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const color = value.trim();
const shortHexMatch = /^#([0-9a-fA-F]{3})$/.exec(color);
if (shortHexMatch) {
const [r, g, b] = shortHexMatch[1].split("");
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
if (/^#[0-9a-fA-F]{6}$/.test(color)) {
return color.toLowerCase();
}
return null;
}
export function normalizeOptionalLogoUrl(value: unknown): string | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed.length < 1 || trimmed.length > 2048) {
return null;
}
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
return trimmed;
}
try {
const url = new URL(trimmed);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.toString();
} catch {
return null;
}
}
export function normalizeOptionalImageUrl(value: unknown): string | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed.length < 1 || trimmed.length > 2048) {
return null;
}
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
return trimmed;
}
try {
const url = new URL(trimmed);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.toString();
} catch {
return null;
}
}
export function normalizeTitle(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const title = value.trim();
if (title.length < 1 || title.length > 80) {
return null;
}
return title;
}
export function normalizeUrl(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed.length < 3 || trimmed.length > 2048) {
return null;
}
const withProtocol =
trimmed.startsWith("http://") || trimmed.startsWith("https://") ? trimmed : `https://${trimmed}`;
try {
const url = new URL(withProtocol);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.toString();
} catch {
return null;
}
}
export function normalizeOptionalUrl(value: unknown): string | null {
if (value === null || value === undefined || value === "") {
return null;
}
return normalizeUrl(value);
}
export function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number): number {
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
return fallback;
}
return parsed;
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
const blockedProbePattern =
/\/(cgi-bin|luci|boaform|HNAP1|\.env|wp-admin|wp-login|phpmyadmin|vendor\/phpunit|shell|setup\.cgi|actuator)/i;
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
/*
* Die App nutzt keine Next Server Actions.
* Fremde/kaputte Action-Requests können Next intern zu x-action-redirect-Fehlern bringen.
*/
if (request.headers.has("next-action")) {
return new NextResponse("Not found", {
status: 404
});
}
if (blockedProbePattern.test(pathname)) {
return new NextResponse("Not found", {
status: 404
});
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|uploads|api/uploads).*)"]
};
+23
View File
@@ -0,0 +1,23 @@
declare module "node-ical" {
export type CalendarComponent = {
type?: string;
uid?: unknown;
summary?: unknown;
start?: unknown;
end?: unknown;
location?: unknown;
};
const ical: {
async: {
fromURL: (
url: string,
options?: {
headers?: Record<string, string>;
}
) => Promise<Record<string, CalendarComponent>>;
};
};
export default ical;
}
+46
View File
@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
"_code_backups",
"**/*.backup*",
"**/*.bak",
"**/*.old",
"**/*.orig",
"**/*.tmp"
]
}