Initial commit: Personal Dashboard
Next.js 16 dashboard with configurable widgets (favorites, notes, calendar, clock, calculator, search, domain-check), multi-tab support, user auth, dark mode, and Docker deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
README.md
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
|
||||
# Local backup folders
|
||||
_code_backups
|
||||
*.backup*
|
||||
*.bak
|
||||
*.old
|
||||
*.orig
|
||||
*.tmp
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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` |
|
||||
@@ -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:
|
||||
@@ -0,0 +1,6 @@
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
seed: "node prisma/seed.mjs"
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(/&/gi, "&")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type BrowserChromeSettings = {
|
||||
dashboardTitle?: string | null;
|
||||
faviconUrl?: string | null;
|
||||
};
|
||||
|
||||
const defaultTitle = "Personal Dashboard";
|
||||
const defaultFaviconUrl = "/favicon.ico";
|
||||
|
||||
function cleanText(value: string | null | undefined, fallback: string): string {
|
||||
const cleanValue = typeof value === "string" ? value.trim() : "";
|
||||
|
||||
return cleanValue || fallback;
|
||||
}
|
||||
|
||||
function applyBrowserChrome(settings: BrowserChromeSettings) {
|
||||
const title = cleanText(settings.dashboardTitle, defaultTitle);
|
||||
const faviconUrl = cleanText(settings.faviconUrl, defaultFaviconUrl);
|
||||
|
||||
document.title = title;
|
||||
|
||||
let faviconLink = document.querySelector<HTMLLinkElement>('link[rel="icon"][data-personal-dashboard="true"]');
|
||||
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement("link");
|
||||
faviconLink.rel = "icon";
|
||||
faviconLink.setAttribute("data-personal-dashboard", "true");
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
|
||||
faviconLink.href = faviconUrl;
|
||||
}
|
||||
|
||||
export default function BrowserChrome() {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { settings?: BrowserChromeSettings } | null;
|
||||
|
||||
if (active && data?.settings) {
|
||||
applyBrowserChrome(data.settings);
|
||||
}
|
||||
} catch {
|
||||
// Browser chrome is cosmetic; dashboard rendering must not fail because of it.
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsUpdated(event: Event) {
|
||||
const customEvent = event as CustomEvent<BrowserChromeSettings>;
|
||||
|
||||
if (customEvent.detail) {
|
||||
applyBrowserChrome(customEvent.detail);
|
||||
}
|
||||
}
|
||||
|
||||
void loadSettings();
|
||||
|
||||
window.addEventListener("personal-dashboard-settings-updated", handleSettingsUpdated);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("personal-dashboard-settings-updated", handleSettingsUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
.calculator {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: clamp(4px, 1.4cqh, 8px);
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calculator:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.display {
|
||||
min-height: clamp(34px, 15cqh, 64px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: clamp(5px, 1.4cqh, 10px) clamp(7px, 2cqw, 12px);
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: clamp(8px, 2cqw, 12px);
|
||||
font-size: clamp(20px, 10cqw, 48px);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.memoryButton {
|
||||
min-width: 0;
|
||||
min-height: clamp(20px, 6cqh, 28px);
|
||||
padding: 0 4px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: clamp(10px, 2.3cqw, 12px);
|
||||
}
|
||||
|
||||
.memoryButton:hover:not(:disabled) {
|
||||
background: var(--surface-strong);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.memoryButton:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: clamp(3px, 0.9cqh, 4px);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-width: 0;
|
||||
min-height: clamp(24px, 8cqh, 38px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: clamp(6px, 1.7cqw, 9px);
|
||||
cursor: pointer;
|
||||
font-size: clamp(12px, 4cqw, 20px);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: var(--accent);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.numberButton {
|
||||
background: var(--surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.operatorButton {
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.equalsButton {
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.utilityButton {
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
@container (max-height: 260px) {
|
||||
.calculator {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.display {
|
||||
min-height: 38px;
|
||||
padding: 5px 8px;
|
||||
font-size: clamp(20px, 8cqw, 32px);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 26px;
|
||||
font-size: clamp(11px, 3.4cqw, 15px);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-height: 210px) {
|
||||
.display {
|
||||
min-height: 30px;
|
||||
padding: 3px 7px;
|
||||
font-size: clamp(18px, 7cqw, 26px);
|
||||
}
|
||||
|
||||
.keypad {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 22px;
|
||||
font-size: clamp(10px, 3cqw, 13px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-height: 160px) {
|
||||
.display {
|
||||
min-height: 26px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 230px) {
|
||||
.display {
|
||||
min-height: 34px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memoryButton {
|
||||
min-height: 22px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 26px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import styles from "./CalculatorWidget.module.css";
|
||||
|
||||
type Operator = "add" | "subtract" | "multiply" | "divide";
|
||||
|
||||
type CalculatorButtonProps = {
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const maxDisplayLength = 16;
|
||||
|
||||
function parseDisplay(value: string): number {
|
||||
const normalizedValue = value.replace(",", ".");
|
||||
const numberValue = Number(normalizedValue);
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "Fehler";
|
||||
}
|
||||
|
||||
if (Object.is(value, -0)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const absoluteValue = Math.abs(value);
|
||||
|
||||
if (absoluteValue !== 0 && (absoluteValue >= 1e15 || absoluteValue < 1e-9)) {
|
||||
return value.toExponential(8).replace(".", ",");
|
||||
}
|
||||
|
||||
const roundedValue = Math.round(value * 1e10) / 1e10;
|
||||
|
||||
return roundedValue.toLocaleString("de-DE", {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 10
|
||||
});
|
||||
}
|
||||
|
||||
function calculate(firstValue: number, secondValue: number, operator: Operator): number {
|
||||
if (operator === "add") {
|
||||
return firstValue + secondValue;
|
||||
}
|
||||
|
||||
if (operator === "subtract") {
|
||||
return firstValue - secondValue;
|
||||
}
|
||||
|
||||
if (operator === "multiply") {
|
||||
return firstValue * secondValue;
|
||||
}
|
||||
|
||||
if (operator === "divide") {
|
||||
if (secondValue === 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
return firstValue / secondValue;
|
||||
}
|
||||
|
||||
return secondValue;
|
||||
}
|
||||
|
||||
function getOperatorLabel(operator: Operator | null): string {
|
||||
if (operator === "add") {
|
||||
return "+";
|
||||
}
|
||||
|
||||
if (operator === "subtract") {
|
||||
return "−";
|
||||
}
|
||||
|
||||
if (operator === "multiply") {
|
||||
return "×";
|
||||
}
|
||||
|
||||
if (operator === "divide") {
|
||||
return "÷";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function CalculatorButton({ label, ariaLabel, className, onClick }: CalculatorButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, className].filter(Boolean).join(" ")}
|
||||
aria-label={ariaLabel ?? label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalculatorWidget() {
|
||||
const [display, setDisplay] = useState("0");
|
||||
const [storedValue, setStoredValue] = useState<number | null>(null);
|
||||
const [pendingOperator, setPendingOperator] = useState<Operator | null>(null);
|
||||
const [waitingForOperand, setWaitingForOperand] = useState(false);
|
||||
const [memory, setMemory] = useState(0);
|
||||
|
||||
const displayIsError = display === "Fehler";
|
||||
|
||||
function resetIfError(): boolean {
|
||||
if (!displayIsError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setDisplay("0");
|
||||
setStoredValue(null);
|
||||
setPendingOperator(null);
|
||||
setWaitingForOperand(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function inputDigit(digit: string) {
|
||||
if (resetIfError()) {
|
||||
setDisplay(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (waitingForOperand) {
|
||||
setDisplay(digit);
|
||||
setWaitingForOperand(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay((currentDisplay) => {
|
||||
if (currentDisplay === "0") {
|
||||
return digit;
|
||||
}
|
||||
|
||||
if (currentDisplay.replace("-", "").replace(",", "").length >= maxDisplayLength) {
|
||||
return currentDisplay;
|
||||
}
|
||||
|
||||
return `${currentDisplay}${digit}`;
|
||||
});
|
||||
}
|
||||
|
||||
function inputDecimal() {
|
||||
if (resetIfError()) {
|
||||
setDisplay("0,");
|
||||
return;
|
||||
}
|
||||
|
||||
if (waitingForOperand) {
|
||||
setDisplay("0,");
|
||||
setWaitingForOperand(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay((currentDisplay) => {
|
||||
if (currentDisplay.includes(",")) {
|
||||
return currentDisplay;
|
||||
}
|
||||
|
||||
return `${currentDisplay},`;
|
||||
});
|
||||
}
|
||||
|
||||
function clearEntry() {
|
||||
setDisplay("0");
|
||||
setWaitingForOperand(false);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setDisplay("0");
|
||||
setStoredValue(null);
|
||||
setPendingOperator(null);
|
||||
setWaitingForOperand(false);
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
if (resetIfError() || waitingForOperand) {
|
||||
setDisplay("0");
|
||||
setWaitingForOperand(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay((currentDisplay) => {
|
||||
if (currentDisplay.length <= 1 || (currentDisplay.length === 2 && currentDisplay.startsWith("-"))) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return currentDisplay.slice(0, -1);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSign() {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay((currentDisplay) => {
|
||||
if (currentDisplay === "0") {
|
||||
return currentDisplay;
|
||||
}
|
||||
|
||||
return currentDisplay.startsWith("-") ? currentDisplay.slice(1) : `-${currentDisplay}`;
|
||||
});
|
||||
}
|
||||
|
||||
function applyUnary(operation: "percent" | "reciprocal" | "square" | "sqrt") {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = parseDisplay(display);
|
||||
let nextValue = currentValue;
|
||||
|
||||
if (operation === "percent") {
|
||||
if (storedValue !== null && pendingOperator) {
|
||||
nextValue = (storedValue * currentValue) / 100;
|
||||
} else {
|
||||
nextValue = currentValue / 100;
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === "reciprocal") {
|
||||
nextValue = currentValue === 0 ? Number.NaN : 1 / currentValue;
|
||||
}
|
||||
|
||||
if (operation === "square") {
|
||||
nextValue = currentValue * currentValue;
|
||||
}
|
||||
|
||||
if (operation === "sqrt") {
|
||||
nextValue = currentValue < 0 ? Number.NaN : Math.sqrt(currentValue);
|
||||
}
|
||||
|
||||
setDisplay(formatNumber(nextValue));
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function chooseOperator(operator: Operator) {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = parseDisplay(display);
|
||||
|
||||
if (storedValue === null) {
|
||||
setStoredValue(currentValue);
|
||||
} else if (pendingOperator && !waitingForOperand) {
|
||||
const result = calculate(storedValue, currentValue, pendingOperator);
|
||||
setDisplay(formatNumber(result));
|
||||
setStoredValue(result);
|
||||
}
|
||||
|
||||
setPendingOperator(operator);
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function applyEquals() {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedValue === null || pendingOperator === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = parseDisplay(display);
|
||||
const result = calculate(storedValue, currentValue, pendingOperator);
|
||||
|
||||
setDisplay(formatNumber(result));
|
||||
setStoredValue(null);
|
||||
setPendingOperator(null);
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function memoryClear() {
|
||||
setMemory(0);
|
||||
}
|
||||
|
||||
function memoryRecall() {
|
||||
setDisplay(formatNumber(memory));
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function memoryAdd() {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemory((currentMemory) => currentMemory + parseDisplay(display));
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function memorySubtract() {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemory((currentMemory) => currentMemory - parseDisplay(display));
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function memoryStore() {
|
||||
if (resetIfError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemory(parseDisplay(display));
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const key = event.key;
|
||||
const code = event.code;
|
||||
let handled = true;
|
||||
|
||||
if (/^[0-9]$/.test(key)) {
|
||||
inputDigit(key);
|
||||
} else if (key === "," || key === "." || code === "NumpadDecimal") {
|
||||
inputDecimal();
|
||||
} else if (key === "+" || code === "NumpadAdd") {
|
||||
chooseOperator("add");
|
||||
} else if (key === "-" || code === "NumpadSubtract") {
|
||||
chooseOperator("subtract");
|
||||
} else if (key === "*" || code === "NumpadMultiply") {
|
||||
chooseOperator("multiply");
|
||||
} else if (key === "/" || code === "NumpadDivide") {
|
||||
chooseOperator("divide");
|
||||
} else if (key === "Enter" || key === "=" || code === "NumpadEnter") {
|
||||
applyEquals();
|
||||
} else if (key === "Backspace") {
|
||||
backspace();
|
||||
} else if (key === "Escape") {
|
||||
clearAll();
|
||||
} else if (key === "Delete") {
|
||||
clearEntry();
|
||||
} else if (key === "%") {
|
||||
applyUnary("percent");
|
||||
} else if (key === "F9") {
|
||||
toggleSign();
|
||||
} else {
|
||||
handled = false;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.calculator} widgetNoDrag`}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label="Taschenrechner"
|
||||
onKeyDown={handleKeyboard}
|
||||
>
|
||||
<output className={styles.display} aria-label="Anzeige">
|
||||
{display}
|
||||
</output>
|
||||
|
||||
<div className={styles.memoryRow} aria-label="Speicherfunktionen">
|
||||
<button type="button" className={styles.memoryButton} onClick={memoryClear} disabled={memory === 0}>
|
||||
MC
|
||||
</button>
|
||||
<button type="button" className={styles.memoryButton} onClick={memoryRecall} disabled={memory === 0}>
|
||||
MR
|
||||
</button>
|
||||
<button type="button" className={styles.memoryButton} onClick={memoryAdd}>
|
||||
M+
|
||||
</button>
|
||||
<button type="button" className={styles.memoryButton} onClick={memorySubtract}>
|
||||
M−
|
||||
</button>
|
||||
<button type="button" className={styles.memoryButton} onClick={memoryStore}>
|
||||
MS
|
||||
</button>
|
||||
<button type="button" className={styles.memoryButton} onClick={memoryRecall} disabled={memory === 0}>
|
||||
M
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.keypad}>
|
||||
<CalculatorButton label="%" className={styles.utilityButton} onClick={() => applyUnary("percent")} />
|
||||
<CalculatorButton label="CE" className={styles.utilityButton} onClick={clearEntry} />
|
||||
<CalculatorButton label="C" className={styles.utilityButton} onClick={clearAll} />
|
||||
<CalculatorButton label="⌫" ariaLabel="Rückschritt" className={styles.utilityButton} onClick={backspace} />
|
||||
|
||||
<CalculatorButton label="1/x" className={styles.utilityButton} onClick={() => applyUnary("reciprocal")} />
|
||||
<CalculatorButton label="x²" className={styles.utilityButton} onClick={() => applyUnary("square")} />
|
||||
<CalculatorButton label="²√x" className={styles.utilityButton} onClick={() => applyUnary("sqrt")} />
|
||||
<CalculatorButton label="÷" className={styles.operatorButton} onClick={() => chooseOperator("divide")} />
|
||||
|
||||
<CalculatorButton label="7" className={styles.numberButton} onClick={() => inputDigit("7")} />
|
||||
<CalculatorButton label="8" className={styles.numberButton} onClick={() => inputDigit("8")} />
|
||||
<CalculatorButton label="9" className={styles.numberButton} onClick={() => inputDigit("9")} />
|
||||
<CalculatorButton label="×" className={styles.operatorButton} onClick={() => chooseOperator("multiply")} />
|
||||
|
||||
<CalculatorButton label="4" className={styles.numberButton} onClick={() => inputDigit("4")} />
|
||||
<CalculatorButton label="5" className={styles.numberButton} onClick={() => inputDigit("5")} />
|
||||
<CalculatorButton label="6" className={styles.numberButton} onClick={() => inputDigit("6")} />
|
||||
<CalculatorButton label="−" className={styles.operatorButton} onClick={() => chooseOperator("subtract")} />
|
||||
|
||||
<CalculatorButton label="1" className={styles.numberButton} onClick={() => inputDigit("1")} />
|
||||
<CalculatorButton label="2" className={styles.numberButton} onClick={() => inputDigit("2")} />
|
||||
<CalculatorButton label="3" className={styles.numberButton} onClick={() => inputDigit("3")} />
|
||||
<CalculatorButton label="+" className={styles.operatorButton} onClick={() => chooseOperator("add")} />
|
||||
|
||||
<CalculatorButton label="+/−" className={styles.numberButton} onClick={toggleSign} />
|
||||
<CalculatorButton label="0" className={styles.numberButton} onClick={() => inputDigit("0")} />
|
||||
<CalculatorButton label="," className={styles.numberButton} onClick={inputDecimal} />
|
||||
<CalculatorButton
|
||||
label="="
|
||||
ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}
|
||||
className={styles.equalsButton}
|
||||
onClick={applyEquals}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.clockWidget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clockCenter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clockTime {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-weight: 900;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.07em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.clockDate {
|
||||
max-width: 100%;
|
||||
color: color-mix(in srgb, var(--text) 74%, transparent);
|
||||
font-weight: 750;
|
||||
line-height: 1.12;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clockWidgetEditMode .clockCenter {
|
||||
gap: 4px;
|
||||
padding: 2px 8px 5px;
|
||||
}
|
||||
|
||||
.clockDateControl {
|
||||
width: min(100%, 210px);
|
||||
display: grid;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.clockDateControlLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clockDateSelect {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0 28px 0 10px;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clockDateSelect:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type ClockWidgetProps = {
|
||||
widgetId: string;
|
||||
editMode: boolean;
|
||||
};
|
||||
|
||||
type DateDisplayMode = "weekday-date" | "date" | "compact" | "hidden";
|
||||
|
||||
type ClockBoxSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const dateDisplayOptions: Array<{
|
||||
value: DateDisplayMode;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
value: "weekday-date",
|
||||
label: "Dienstag, 12. Mai"
|
||||
},
|
||||
{
|
||||
value: "date",
|
||||
label: "12. Mai 2026"
|
||||
},
|
||||
{
|
||||
value: "compact",
|
||||
label: "12.05.2026"
|
||||
},
|
||||
{
|
||||
value: "hidden",
|
||||
label: "Datum ausblenden"
|
||||
}
|
||||
];
|
||||
|
||||
function getStorageKey(widgetId: string): string {
|
||||
return `personal-dashboard-clock-date-mode-${widgetId}`;
|
||||
}
|
||||
|
||||
function isDateDisplayMode(value: string | null): value is DateDisplayMode {
|
||||
return value === "weekday-date" || value === "date" || value === "compact" || value === "hidden";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDate(date: Date, mode: DateDisplayMode): string {
|
||||
if (mode === "hidden") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (mode === "compact") {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
if (mode === "date") {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClockSizing(size: ClockBoxSize, editMode: boolean, dateDisplayMode: DateDisplayMode) {
|
||||
const width = size.width > 0 ? size.width : 220;
|
||||
const height = size.height > 0 ? size.height : 120;
|
||||
|
||||
const controlHeight = editMode ? 34 : 0;
|
||||
const availableHeight = Math.max(32, height - controlHeight);
|
||||
const showDate = dateDisplayMode !== "hidden" && availableHeight >= 42;
|
||||
|
||||
const timeFontSize = Math.floor(
|
||||
clamp(
|
||||
Math.min(
|
||||
width * 0.39,
|
||||
availableHeight * (showDate ? 0.62 : 0.78),
|
||||
editMode ? 62 : 82
|
||||
),
|
||||
availableHeight < 48 ? 18 : 24,
|
||||
editMode ? 62 : 82
|
||||
)
|
||||
);
|
||||
|
||||
const dateFontSize = Math.floor(clamp(Math.min(width * 0.06, availableHeight * 0.15), 9, 14));
|
||||
|
||||
return {
|
||||
timeFontSize,
|
||||
dateFontSize,
|
||||
showDate
|
||||
};
|
||||
}
|
||||
|
||||
export default function ClockWidget({ widgetId, editMode }: ClockWidgetProps) {
|
||||
const widgetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [dateDisplayMode, setDateDisplayMode] = useState<DateDisplayMode>("weekday-date");
|
||||
const [boxSize, setBoxSize] = useState<ClockBoxSize>({
|
||||
width: 220,
|
||||
height: 120
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const savedMode = window.localStorage.getItem(getStorageKey(widgetId));
|
||||
|
||||
if (isDateDisplayMode(savedMode)) {
|
||||
setDateDisplayMode(savedMode);
|
||||
}
|
||||
}, [widgetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentElement = widgetRef.current;
|
||||
|
||||
if (currentElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observedElement: HTMLDivElement = currentElement;
|
||||
|
||||
function updateSize() {
|
||||
const rect = observedElement.getBoundingClientRect();
|
||||
|
||||
setBoxSize({
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
}
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(observedElement);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setNow(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const timeLabel = useMemo(() => formatTime(now), [now]);
|
||||
const dateLabel = useMemo(() => formatDate(now, dateDisplayMode), [now, dateDisplayMode]);
|
||||
const sizing = useMemo(() => getClockSizing(boxSize, editMode, dateDisplayMode), [boxSize, editMode, dateDisplayMode]);
|
||||
|
||||
function updateDateDisplayMode(nextMode: DateDisplayMode) {
|
||||
setDateDisplayMode(nextMode);
|
||||
window.localStorage.setItem(getStorageKey(widgetId), nextMode);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={widgetRef} className={editMode ? "pdClockWidget pdClockWidgetEditMode" : "pdClockWidget"}>
|
||||
<div className="pdClockCenter">
|
||||
<time
|
||||
className="pdClockTime"
|
||||
dateTime={now.toISOString()}
|
||||
style={{
|
||||
fontSize: `${sizing.timeFontSize}px`
|
||||
}}
|
||||
>
|
||||
{timeLabel}
|
||||
</time>
|
||||
|
||||
{dateLabel && sizing.showDate ? (
|
||||
<div
|
||||
className="pdClockDate"
|
||||
style={{
|
||||
fontSize: `${sizing.dateFontSize}px`
|
||||
}}
|
||||
>
|
||||
{dateLabel}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editMode ? (
|
||||
<label className="pdClockDateControl widgetNoDrag">
|
||||
<span className="pdClockDateControlLabel">Datumsanzeige</span>
|
||||
|
||||
<select
|
||||
className="pdClockDateSelect"
|
||||
value={dateDisplayMode}
|
||||
onChange={(event) => updateDateDisplayMode(event.target.value as DateDisplayMode)}
|
||||
>
|
||||
{dateDisplayOptions.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import ReactGridLayoutBase, { WidthProvider } from "react-grid-layout/legacy";
|
||||
import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout";
|
||||
|
||||
export type DashboardGridProps = {
|
||||
widgets: DashboardGridWidget[];
|
||||
editMode: boolean;
|
||||
activeMenuWidgetId?: string | null;
|
||||
renderWidget: (widget: DashboardGridWidget) => ReactNode;
|
||||
onLayoutChange: (layout: DashboardLayoutItem[]) => void;
|
||||
};
|
||||
|
||||
const WidthAwareGridLayout = WidthProvider(ReactGridLayoutBase) as ComponentType<any>;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getWidgetMinimumSize(widget: DashboardGridWidget): { minW: number; minH: number } {
|
||||
if (widget.type === "search") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 5
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "clock") {
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 4
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "calendar") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 6
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "calculator") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 6
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "note") {
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 5
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 4
|
||||
};
|
||||
}
|
||||
|
||||
function widgetsToLayout(widgets: DashboardGridWidget[]): DashboardLayoutItem[] {
|
||||
return widgets.map((widget) => {
|
||||
const minimumSize = getWidgetMinimumSize(widget);
|
||||
const width = clamp(widget.w, minimumSize.minW, 48);
|
||||
const height = clamp(widget.h, minimumSize.minH, 180);
|
||||
const x = clamp(widget.x, 0, 48 - width);
|
||||
const y = clamp(widget.y, 0, 10000);
|
||||
|
||||
return {
|
||||
i: widget.id,
|
||||
x,
|
||||
y,
|
||||
w: width,
|
||||
h: height,
|
||||
minW: minimumSize.minW,
|
||||
minH: minimumSize.minH,
|
||||
maxW: 48,
|
||||
maxH: 180
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLayout(layout: readonly DashboardLayoutItem[]): DashboardLayoutItem[] {
|
||||
return layout.map((item) => ({
|
||||
i: String(item.i),
|
||||
x: Number.isFinite(item.x) ? item.x : 0,
|
||||
y: Number.isFinite(item.y) ? item.y : 0,
|
||||
w: Number.isFinite(item.w) ? item.w : 1,
|
||||
h: Number.isFinite(item.h) ? item.h : 1,
|
||||
minW: item.minW,
|
||||
minH: item.minH,
|
||||
maxW: item.maxW,
|
||||
maxH: item.maxH
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DashboardGrid({
|
||||
widgets,
|
||||
editMode,
|
||||
activeMenuWidgetId = null,
|
||||
renderWidget,
|
||||
onLayoutChange
|
||||
}: DashboardGridProps) {
|
||||
const layout = useMemo(() => widgetsToLayout(widgets), [widgets]);
|
||||
|
||||
return (
|
||||
<div className="widgetGridShell">
|
||||
{widgets.length === 0 ? (
|
||||
<div className="emptyDashboard">
|
||||
<h2>Keine Widgets aktiv</h2>
|
||||
<p className="muted">Schalte den Bearbeitungsmodus ein und füge Widgets hinzu.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widgets.length > 0 ? (
|
||||
<WidthAwareGridLayout
|
||||
className="widgetGrid"
|
||||
layout={layout}
|
||||
cols={48}
|
||||
rowHeight={8}
|
||||
margin={[12, 12]}
|
||||
containerPadding={[0, 0]}
|
||||
compactType={null}
|
||||
preventCollision={true}
|
||||
isBounded={false}
|
||||
autoSize={true}
|
||||
isDraggable={editMode}
|
||||
isResizable={editMode}
|
||||
draggableHandle=".widgetDragHandle"
|
||||
draggableCancel=".widgetNoDrag"
|
||||
resizeHandles={["se"]}
|
||||
measureBeforeMount={true}
|
||||
onLayoutChange={(nextLayout: DashboardLayoutItem[]) => onLayoutChange(normalizeLayout(nextLayout))}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className={activeMenuWidgetId === widget.id ? "gridItemMenuOpen" : ""}
|
||||
>
|
||||
{renderWidget(widget)}
|
||||
</div>
|
||||
))}
|
||||
</WidthAwareGridLayout>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
type DomainCheckStatus = "idle" | "invalid" | "available" | "registered" | "unknown" | "loading";
|
||||
|
||||
type DomainCheckResult = {
|
||||
status: Exclude<DomainCheckStatus, "idle" | "loading">;
|
||||
domain?: string;
|
||||
asciiDomain?: string;
|
||||
message: string;
|
||||
registrar?: string | null;
|
||||
nameservers?: string[];
|
||||
rdapUrl?: string;
|
||||
whoisServer?: string | null;
|
||||
source?: string;
|
||||
checkedAt?: string;
|
||||
};
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json().catch(() => null)) as T | null;
|
||||
|
||||
if (!response.ok && data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ungültige Server-Antwort.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeDomainInput(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getStatusLabel(status: DomainCheckStatus): string {
|
||||
if (status === "available") {
|
||||
return "Frei";
|
||||
}
|
||||
|
||||
if (status === "registered") {
|
||||
return "Belegt";
|
||||
}
|
||||
|
||||
if (status === "invalid") {
|
||||
return "Ungültig";
|
||||
}
|
||||
|
||||
if (status === "unknown") {
|
||||
return "Unklar";
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return "Prüft";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getStatusTitle(status: DomainCheckStatus, result: DomainCheckResult | null): string {
|
||||
if (result?.message) {
|
||||
return result.message;
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
return "Domain ist frei.";
|
||||
}
|
||||
|
||||
if (status === "registered") {
|
||||
return "Domain ist bereits registriert.";
|
||||
}
|
||||
|
||||
if (status === "invalid") {
|
||||
return "Domain ist ungültig.";
|
||||
}
|
||||
|
||||
if (status === "unknown") {
|
||||
return "Status konnte nicht eindeutig geprüft werden.";
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return "Domain wird geprüft.";
|
||||
}
|
||||
|
||||
return "Noch nicht geprüft.";
|
||||
}
|
||||
|
||||
export default function DomainCheckWidget() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DomainCheckResult | null>(null);
|
||||
|
||||
async function checkDomain(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const cleanDomain = normalizeDomainInput(domain);
|
||||
|
||||
if (!cleanDomain) {
|
||||
setResult({
|
||||
status: "invalid",
|
||||
message: "Bitte eine Domain eingeben."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/domain-check?domain=${encodeURIComponent(cleanDomain)}`, {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<DomainCheckResult>(response);
|
||||
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
setResult({
|
||||
status: "unknown",
|
||||
message: error instanceof Error ? error.message : "Domainprüfung fehlgeschlagen."
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const status: DomainCheckStatus = loading ? "loading" : result?.status ?? "idle";
|
||||
const statusLabel = getStatusLabel(status);
|
||||
const statusTitle = getStatusTitle(status, result);
|
||||
|
||||
return (
|
||||
<form className="domainCheckWidget widgetNoDrag" onSubmit={checkDomain}>
|
||||
<input
|
||||
className="input domainCheckInput"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="example.com"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-label="Domain"
|
||||
/>
|
||||
|
||||
<span
|
||||
className={`domainCheckStatus domainCheckStatus-${status}`}
|
||||
title={statusTitle}
|
||||
aria-label={statusTitle}
|
||||
>
|
||||
<span className="domainCheckStatusDot" aria-hidden="true" />
|
||||
{statusLabel ? <span className="domainCheckStatusText">{statusLabel}</span> : null}
|
||||
</span>
|
||||
|
||||
<button type="submit" className="button domainCheckButton" disabled={loading}>
|
||||
{loading ? "..." : "Prüfen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type FavoriteLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type FavoritesWidgetProps = {
|
||||
widgetId: string;
|
||||
editMode: boolean;
|
||||
viewMode?: "list" | "grid";
|
||||
onViewModeChange?: (viewMode: "list" | "grid") => void;
|
||||
};
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json().catch(() => null)) as T | null;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data && typeof data === "object" && "error" in data && typeof data.error === "string"
|
||||
? data.error
|
||||
: "Anfrage fehlgeschlagen.";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ungültige Server-Antwort.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function createClientId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getFavoriteInitial(title: string): string {
|
||||
const cleanTitle = title.trim();
|
||||
|
||||
if (!cleanTitle) {
|
||||
return "?";
|
||||
}
|
||||
|
||||
return cleanTitle.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getFallbackFaviconUrl(url: string): string | null {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return new URL("/favicon.ico", parsedUrl.origin).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFavoriteIconUrl(favorite: FavoriteLink): string | null {
|
||||
return favorite.iconUrl || getFallbackFaviconUrl(favorite.url);
|
||||
}
|
||||
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<svg className="favoriteActionIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg className="favoriteActionIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M7 21c-.6 0-1.1-.2-1.5-.7A2 2 0 0 1 5 18.9V8H4V6h5V4h6v2h5v2h-1v10.9c0 .6-.2 1.1-.7 1.5-.4.4-.9.6-1.5.6H7ZM17 8H7v10.9l.1.1h9.8l.1-.1V8Zm-8 9h2v-7H9v7Zm4 0h2v-7h-2v7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesWidget({
|
||||
widgetId,
|
||||
editMode,
|
||||
viewMode = "list",
|
||||
onViewModeChange
|
||||
}: FavoritesWidgetProps) {
|
||||
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newUrl, setNewUrl] = useState("");
|
||||
const [newIconUrl, setNewIconUrl] = useState("");
|
||||
const [favoriteError, setFavoriteError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [draggingFavoriteId, setDraggingFavoriteId] = useState<string | null>(null);
|
||||
|
||||
const [editingFavoriteId, setEditingFavoriteId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editUrl, setEditUrl] = useState("");
|
||||
const [editIconUrl, setEditIconUrl] = useState("");
|
||||
|
||||
const sortedFavorites = useMemo(() => {
|
||||
return [...favorites].sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [favorites]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFavorites();
|
||||
}, [widgetId]);
|
||||
|
||||
async function loadFavorites() {
|
||||
setLoading(true);
|
||||
setFavoriteError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites?widgetId=${encodeURIComponent(widgetId)}`, {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorites: FavoriteLink[] }>(response);
|
||||
|
||||
setFavorites(data.favorites);
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favoriten konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addFavorite(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setFavoriteError(null);
|
||||
|
||||
const cleanTitle = newTitle.trim();
|
||||
const cleanUrl = newUrl.trim();
|
||||
const cleanIconUrl = newIconUrl.trim();
|
||||
|
||||
if (!cleanTitle || !cleanUrl) {
|
||||
setFavoriteError("Titel und URL sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/favorites", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widgetId,
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorite: FavoriteLink }>(response);
|
||||
|
||||
setFavorites((current) => [...current, data.favorite]);
|
||||
setNewTitle("");
|
||||
setNewUrl("");
|
||||
setNewIconUrl("");
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavorite(favoriteId: string) {
|
||||
setFavoriteError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${encodeURIComponent(favoriteId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
|
||||
throw new Error(data?.error ?? "Favorit konnte nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
setFavorites((current) => current.filter((favorite) => favorite.id !== favoriteId));
|
||||
|
||||
if (editingFavoriteId === favoriteId) {
|
||||
cancelEditingFavorite();
|
||||
}
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingFavorite(favorite: FavoriteLink) {
|
||||
setEditingFavoriteId(favorite.id);
|
||||
setEditTitle(favorite.title);
|
||||
setEditUrl(favorite.url);
|
||||
setEditIconUrl(favorite.iconUrl ?? "");
|
||||
setFavoriteError(null);
|
||||
}
|
||||
|
||||
function cancelEditingFavorite() {
|
||||
setEditingFavoriteId(null);
|
||||
setEditTitle("");
|
||||
setEditUrl("");
|
||||
setEditIconUrl("");
|
||||
}
|
||||
|
||||
async function saveEditedFavorite(favoriteId: string) {
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
const cleanIconUrl = editIconUrl.trim();
|
||||
|
||||
if (!cleanTitle || !cleanUrl) {
|
||||
setFavoriteError("Titel und URL sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${encodeURIComponent(favoriteId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorite: FavoriteLink }>(response);
|
||||
|
||||
setFavorites((current) => current.map((favorite) => (favorite.id === favoriteId ? data.favorite : favorite)));
|
||||
cancelEditingFavorite();
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function persistFavoriteOrder(nextFavorites: FavoriteLink[]) {
|
||||
try {
|
||||
await Promise.all(
|
||||
nextFavorites.map((favorite, index) =>
|
||||
fetch(`/api/favorites/${encodeURIComponent(favorite.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position: index
|
||||
})
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Favoriten-Reihenfolge konnte nicht gespeichert werden.");
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favoriten-Reihenfolge konnte nicht gespeichert werden.");
|
||||
void loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
function reorderFavorites(sourceId: string, targetId: string) {
|
||||
if (sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedFavorites = [...sortedFavorites];
|
||||
const sourceIndex = orderedFavorites.findIndex((favorite) => favorite.id === sourceId);
|
||||
const targetIndex = orderedFavorites.findIndex((favorite) => favorite.id === targetId);
|
||||
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedFavorite] = orderedFavorites.splice(sourceIndex, 1);
|
||||
orderedFavorites.splice(targetIndex, 0, movedFavorite);
|
||||
|
||||
const nextFavorites = orderedFavorites.map((favorite, index) => ({
|
||||
...favorite,
|
||||
position: index
|
||||
}));
|
||||
|
||||
setFavorites(nextFavorites);
|
||||
void persistFavoriteOrder(nextFavorites);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={viewMode === "grid" ? "favoritesWidget favoritesWidgetGridMode" : "favoritesWidget"}>
|
||||
{editMode ? (
|
||||
<div className="favoriteViewModeToggle widgetNoDrag" aria-label="Favoriten-Darstellung">
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === "list" ? "favoriteViewModeButton favoriteViewModeButtonActive" : "favoriteViewModeButton"}
|
||||
onClick={() => onViewModeChange?.("list")}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === "grid" ? "favoriteViewModeButton favoriteViewModeButtonActive" : "favoriteViewModeButton"}
|
||||
onClick={() => onViewModeChange?.("grid")}
|
||||
>
|
||||
Kacheln
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <p className="muted">Favoriten werden geladen...</p> : null}
|
||||
{favoriteError ? <p className="errorText">{favoriteError}</p> : null}
|
||||
|
||||
<div className="favoriteTileList">
|
||||
{sortedFavorites.length === 0 && !loading ? <p className="muted">Noch keine Favoriten.</p> : null}
|
||||
|
||||
{sortedFavorites.map((favorite) => {
|
||||
const iconUrl = getFavoriteIconUrl(favorite);
|
||||
const isEditing = editingFavoriteId === favorite.id;
|
||||
const dragKey = createClientId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"favoriteSortableItem",
|
||||
draggingFavoriteId === favorite.id ? "favoriteSortableItemDragging" : "",
|
||||
isEditing ? "favoriteSortableItemEditing" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={favorite.id}
|
||||
draggable={editMode && !isEditing}
|
||||
onDragStart={(event) => {
|
||||
setDraggingFavoriteId(favorite.id);
|
||||
event.dataTransfer.setData("text/plain", favorite.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const sourceId = event.dataTransfer.getData("text/plain");
|
||||
|
||||
if (sourceId) {
|
||||
reorderFavorites(sourceId, favorite.id);
|
||||
}
|
||||
|
||||
setDraggingFavoriteId(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggingFavoriteId(null)}
|
||||
data-drag-key={dragKey}
|
||||
>
|
||||
<a className="favoriteTile widgetNoDrag" href={favorite.url} target="_blank" rel="noreferrer">
|
||||
<div className="favoriteIcon">
|
||||
<span className="favoriteIconFallback">{getFavoriteInitial(favorite.title)}</span>
|
||||
|
||||
{iconUrl ? (
|
||||
<img
|
||||
className="favoriteIconImage"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="favoriteTitle">{favorite.title}</span>
|
||||
</a>
|
||||
|
||||
{editMode ? (
|
||||
<div className="favoriteItemActions widgetNoDrag">
|
||||
<button
|
||||
type="button"
|
||||
className="favoriteEditButton"
|
||||
onClick={() => startEditingFavorite(favorite)}
|
||||
aria-label="Favorit bearbeiten"
|
||||
title="Favorit bearbeiten"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="favoriteDeleteButton"
|
||||
onClick={() => void removeFavorite(favorite.id)}
|
||||
aria-label="Favorit löschen"
|
||||
title="Favorit löschen"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editMode && isEditing ? (
|
||||
<div className="favoriteInlineEditForm widgetNoDrag">
|
||||
<input
|
||||
className="input"
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
placeholder="Titel"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={editUrl}
|
||||
onChange={(event) => setEditUrl(event.target.value)}
|
||||
placeholder="URL"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={editIconUrl}
|
||||
onChange={(event) => setEditIconUrl(event.target.value)}
|
||||
placeholder="Logo-Bild-URL optional"
|
||||
/>
|
||||
|
||||
<div className="favoriteInlineEditActions">
|
||||
<button type="button" className="button buttonSecondary" onClick={() => void saveEditedFavorite(favorite.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
<button type="button" className="button buttonSecondary" onClick={cancelEditingFavorite}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<form className="favoriteAddForm widgetNoDrag" onSubmit={addFavorite}>
|
||||
<input className="input" value={newTitle} onChange={(event) => setNewTitle(event.target.value)} placeholder="Titel" />
|
||||
|
||||
<input className="input" value={newUrl} onChange={(event) => setNewUrl(event.target.value)} placeholder="URL" />
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={newIconUrl}
|
||||
onChange={(event) => setNewIconUrl(event.target.value)}
|
||||
placeholder="Logo-Bild-URL optional"
|
||||
/>
|
||||
|
||||
<button type="submit" className="button">
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+140
@@ -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;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).*)"]
|
||||
};
|
||||
Vendored
+23
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user