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