From a4051ae1322c06df4456f4bbc0ccccfb2c061817 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 10:02:05 +0200 Subject: [PATCH] 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 --- .dockerignore | 15 + .env.example | 5 + .gitignore | 25 + Dockerfile | 49 + README.md | 49 + docker-compose.yml | 31 + next.config.mjs | 6 + package.json | 32 + prisma.config.ts | 8 + prisma/schema.prisma | 184 ++ prisma/seed.mjs | 106 + public/.gitkeep | 0 public/background-fancy.svg | 29 + public/logo.svg | 4 + src/app/admin.css | 147 + src/app/admin/page.tsx | 85 + src/app/admin/settings/page.tsx | 23 + src/app/admin/users/page.tsx | 394 +++ src/app/api/admin/users/route.ts | 181 ++ src/app/api/auth/login/route.ts | 68 + src/app/api/auth/logout/route.ts | 10 + src/app/api/auth/me/route.ts | 10 + src/app/api/calendar/route.ts | 118 + src/app/api/calendar/source/route.ts | 589 ++++ src/app/api/domain-check/route.ts | 535 ++++ src/app/api/favorites/[id]/route.ts | 184 ++ src/app/api/favorites/route.ts | 413 +++ src/app/api/notes/[id]/route.ts | 118 + src/app/api/notes/route.ts | 179 ++ src/app/api/settings/background/route.ts | 107 + src/app/api/settings/favicon/route.ts | 123 + src/app/api/settings/logo/route.ts | 122 + src/app/api/settings/route.ts | 265 ++ src/app/api/tabs/[id]/route.ts | 167 + src/app/api/tabs/route.ts | 117 + src/app/api/uploads/[...path]/route.ts | 71 + src/app/api/uploads/logo/[file]/route.ts | 63 + src/app/api/uploads/logo/route.ts | 176 + src/app/api/users/[id]/route.ts | 208 ++ src/app/api/users/route.ts | 197 ++ src/app/api/widgets/[id]/route.ts | 199 ++ src/app/api/widgets/route.ts | 385 +++ src/app/calendar-scale.css | 347 ++ src/app/clock-widget.css | 204 ++ src/app/compact-widgets.css | 378 +++ src/app/domain-check-widget.css | 115 + src/app/favorites-widget.css | 424 +++ src/app/globals.css | 3382 ++++++++++++++++++++ src/app/layout.tsx | 32 + src/app/note-widget.css | 210 ++ src/app/page.tsx | 2370 ++++++++++++++ src/app/search-widget.css | 378 +++ src/app/settings/page.tsx | 924 ++++++ src/app/toolbar-fixes.css | 162 + src/app/user-theme.css | 77 + src/app/widget-density.css | 505 +++ src/components/BrowserChrome.tsx | 80 + src/components/CalculatorWidget.module.css | 186 ++ src/components/CalculatorWidget.tsx | 434 +++ src/components/ClockWidget.module.css | 87 + src/components/ClockWidget.tsx | 219 ++ src/components/DashboardGrid.tsx | 150 + src/components/DomainCheckWidget.tsx | 161 + src/components/FavoritesWidget.tsx | 482 +++ src/lib/auth.ts | 140 + src/lib/calendar-fetchers.ts | 354 ++ src/lib/dashboard-layout.ts | 40 + src/lib/favorite-icons.ts | 280 ++ src/lib/prisma.ts | 15 + src/lib/secret-crypto.ts | 57 + src/lib/validation.ts | 228 ++ src/proxy.ts | 30 + src/types/node-ical.d.ts | 23 + tsconfig.json | 46 + 74 files changed, 18317 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.mjs create mode 100644 public/.gitkeep create mode 100644 public/background-fancy.svg create mode 100644 public/logo.svg create mode 100644 src/app/admin.css create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/calendar/route.ts create mode 100644 src/app/api/calendar/source/route.ts create mode 100644 src/app/api/domain-check/route.ts create mode 100644 src/app/api/favorites/[id]/route.ts create mode 100644 src/app/api/favorites/route.ts create mode 100644 src/app/api/notes/[id]/route.ts create mode 100644 src/app/api/notes/route.ts create mode 100644 src/app/api/settings/background/route.ts create mode 100644 src/app/api/settings/favicon/route.ts create mode 100644 src/app/api/settings/logo/route.ts create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/api/tabs/[id]/route.ts create mode 100644 src/app/api/tabs/route.ts create mode 100644 src/app/api/uploads/[...path]/route.ts create mode 100644 src/app/api/uploads/logo/[file]/route.ts create mode 100644 src/app/api/uploads/logo/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/api/widgets/[id]/route.ts create mode 100644 src/app/api/widgets/route.ts create mode 100644 src/app/calendar-scale.css create mode 100644 src/app/clock-widget.css create mode 100644 src/app/compact-widgets.css create mode 100644 src/app/domain-check-widget.css create mode 100644 src/app/favorites-widget.css create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/note-widget.css create mode 100644 src/app/page.tsx create mode 100644 src/app/search-widget.css create mode 100644 src/app/settings/page.tsx create mode 100644 src/app/toolbar-fixes.css create mode 100644 src/app/user-theme.css create mode 100644 src/app/widget-density.css create mode 100644 src/components/BrowserChrome.tsx create mode 100644 src/components/CalculatorWidget.module.css create mode 100644 src/components/CalculatorWidget.tsx create mode 100644 src/components/ClockWidget.module.css create mode 100644 src/components/ClockWidget.tsx create mode 100644 src/components/DashboardGrid.tsx create mode 100644 src/components/DomainCheckWidget.tsx create mode 100644 src/components/FavoritesWidget.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/calendar-fetchers.ts create mode 100644 src/lib/dashboard-layout.ts create mode 100644 src/lib/favorite-icons.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/secret-crypto.ts create mode 100644 src/lib/validation.ts create mode 100644 src/proxy.ts create mode 100644 src/types/node-ical.d.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3658726 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..866de41 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed08830 --- /dev/null +++ b/.gitignore @@ -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-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc52b2c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2afaa3a --- /dev/null +++ b/README.md @@ -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` | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..03d0f6f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..e6adb30 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,6 @@ +const nextConfig = { + output: "standalone", + reactStrictMode: true +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..55489aa --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..572f72c --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + seed: "node prisma/seed.mjs" + } +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d72610c --- /dev/null +++ b/prisma/schema.prisma @@ -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 +} diff --git a/prisma/seed.mjs b/prisma/seed.mjs new file mode 100644 index 0000000..ae6ac60 --- /dev/null +++ b/prisma/seed.mjs @@ -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(); + }); diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/background-fancy.svg b/public/background-fancy.svg new file mode 100644 index 0000000..5d0559b --- /dev/null +++ b/public/background-fancy.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..278a919 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/admin.css b/src/app/admin.css new file mode 100644 index 0000000..9482bab --- /dev/null +++ b/src/app/admin.css @@ -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; + } +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..a1215ee --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Settings = { + darkMode: boolean; +}; + +async function parseJsonResponse(response: Response): Promise { + 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(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 ( +
+
+
+
+
Administration
+
Systemverwaltung
+
+
+ + +
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..0b74cc1 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect } from "react"; + +export default function AdminSettingsRedirectPage() { + useEffect(() => { + window.location.replace("/settings"); + }, []); + + return ( +
+
+
+

Weiterleitung

+

Du wirst zu den Einstellungen weitergeleitet.

+ + Einstellungen öffnen + +
+
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..8c9b6ac --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -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(response: Response): Promise { + 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(null); + const [settings, setSettings] = useState(null); + const [users, setUsers] = useState([]); + const [editingUserId, setEditingUserId] = useState(null); + const [editingById, setEditingById] = useState>({}); + const [loading, setLoading] = useState(true); + const [savingUserId, setSavingUserId] = useState(null); + const [deletingUserId, setDeletingUserId] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(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) { + 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, 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 ( +
+
+
+
+
Benutzerverwaltung
+
Benutzer bearbeiten und löschen
+
+
+ + +
+ +
+
+
+
+

Benutzer

+

Name und E-Mail bearbeiten. Die Datenbank-ID bleibt unverändert.

+
+
+ + {loading ?

Lade Benutzer...

: null} + {error ?

{error}

: null} + {message ?

{message}

: null} + + {!loading && users.length === 0 ?

Keine Benutzer vorhanden.

: null} + + {!loading && users.length > 0 ? ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + + + + ); + })} + +
NameE-MailRolleIDAktionen
+ {isEditing ? ( + updateDraft(user.id, { displayName: event.target.value })} + placeholder="Name optional" + maxLength={120} + /> + ) : ( + {user.displayName || "—"} + )} + + {isEditing ? ( + updateDraft(user.id, { email: event.target.value })} + required + /> + ) : ( + {user.email} + )} + + {isCurrentUser ? Du : null} + {formatUserRole(user.role)} + {user.id} + + {isEditing ? ( +
void saveUser(event, user.id)}> + + + +
+ ) : ( +
+ + + +
+ )} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..d8fd60f --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..11a1222 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 + } + }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..9c47c96 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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 + }); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..ab266dc --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 + }); +} diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts new file mode 100644 index 0000000..87a07f2 --- /dev/null +++ b/src/app/api/calendar/route.ts @@ -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." + }); + } +} diff --git a/src/app/api/calendar/source/route.ts b/src/app/api/calendar/source/route.ts new file mode 100644 index 0000000..7333887 --- /dev/null +++ b/src/app/api/calendar/source/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/domain-check/route.ts b/src/app/api/domain-check/route.ts new file mode 100644 index 0000000..84997be --- /dev/null +++ b/src/app/api/domain-check/route.ts @@ -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 { + 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 { + 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 { + const ianaResponse = await queryWhois("whois.iana.org", tld); + const referredServer = parseWhoisServerFromIana(ianaResponse); + + if (referredServer) { + return referredServer; + } + + const fallbackServers: Record = { + 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; + 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 } + ); + } +} diff --git a/src/app/api/favorites/[id]/route.ts b/src/app/api/favorites/[id]/route.ts new file mode 100644 index 0000000..d48b618 --- /dev/null +++ b/src/app/api/favorites/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts new file mode 100644 index 0000000..2b35f03 --- /dev/null +++ b/src/app/api/favorites/route.ts @@ -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 { + 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(/]*>/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 { + 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 } + ); + } +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts new file mode 100644 index 0000000..d2e0fa1 --- /dev/null +++ b/src/app/api/notes/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts new file mode 100644 index 0000000..e49d436 --- /dev/null +++ b/src/app/api/notes/route.ts @@ -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 }); + } +} diff --git a/src/app/api/settings/background/route.ts b/src/app/api/settings/background/route.ts new file mode 100644 index 0000000..f4a801a --- /dev/null +++ b/src/app/api/settings/background/route.ts @@ -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 = { + "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 }); + } +} diff --git a/src/app/api/settings/favicon/route.ts b/src/app/api/settings/favicon/route.ts new file mode 100644 index 0000000..4c5ef87 --- /dev/null +++ b/src/app/api/settings/favicon/route.ts @@ -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 = { + "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 }); + } +} diff --git a/src/app/api/settings/logo/route.ts b/src/app/api/settings/logo/route.ts new file mode 100644 index 0000000..367787a --- /dev/null +++ b/src/app/api/settings/logo/route.ts @@ -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 = { + "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 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..fac4c6f --- /dev/null +++ b/src/app/api/settings/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tabs/[id]/route.ts b/src/app/api/tabs/[id]/route.ts new file mode 100644 index 0000000..eb7c824 --- /dev/null +++ b/src/app/api/tabs/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tabs/route.ts b/src/app/api/tabs/route.ts new file mode 100644 index 0000000..939af24 --- /dev/null +++ b/src/app/api/tabs/route.ts @@ -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 }); + } +} diff --git a/src/app/api/uploads/[...path]/route.ts b/src/app/api/uploads/[...path]/route.ts new file mode 100644 index 0000000..547f43d --- /dev/null +++ b/src/app/api/uploads/[...path]/route.ts @@ -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 = { + ".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 }); + } +} diff --git a/src/app/api/uploads/logo/[file]/route.ts b/src/app/api/uploads/logo/[file]/route.ts new file mode 100644 index 0000000..0d995d6 --- /dev/null +++ b/src/app/api/uploads/logo/[file]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/uploads/logo/route.ts b/src/app/api/uploads/logo/route.ts new file mode 100644 index 0000000..d578434 --- /dev/null +++ b/src/app/api/uploads/logo/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..a29718b --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..68a6340 --- /dev/null +++ b/src/app/api/users/route.ts @@ -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 }); + } +} diff --git a/src/app/api/widgets/[id]/route.ts b/src/app/api/widgets/[id]/route.ts new file mode 100644 index 0000000..84ff600 --- /dev/null +++ b/src/app/api/widgets/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/widgets/route.ts b/src/app/api/widgets/route.ts new file mode 100644 index 0000000..f08ff5a --- /dev/null +++ b/src/app/api/widgets/route.ts @@ -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 = { + 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 } + ); + } +} diff --git a/src/app/calendar-scale.css b/src/app/calendar-scale.css new file mode 100644 index 0000000..bdfe142 --- /dev/null +++ b/src/app/calendar-scale.css @@ -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; + } +} diff --git a/src/app/clock-widget.css b/src/app/clock-widget.css new file mode 100644 index 0000000..2f25a2a --- /dev/null +++ b/src/app/clock-widget.css @@ -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; +} diff --git a/src/app/compact-widgets.css b/src/app/compact-widgets.css new file mode 100644 index 0000000..8bf5c98 --- /dev/null +++ b/src/app/compact-widgets.css @@ -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; +} diff --git a/src/app/domain-check-widget.css b/src/app/domain-check-widget.css new file mode 100644 index 0000000..ce70d90 --- /dev/null +++ b/src/app/domain-check-widget.css @@ -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; + } +} diff --git a/src/app/favorites-widget.css b/src/app/favorites-widget.css new file mode 100644 index 0000000..c6114dc --- /dev/null +++ b/src/app/favorites-widget.css @@ -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; +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..5abe4ec --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,3382 @@ +:root { + color-scheme: light; + --background: #f5f7fb; + --surface: #ffffff; + --surface-strong: #f0f2f7; + --text: #111827; + --muted: #6b7280; + --border: #d1d5db; + --accent: #2563eb; + --accent-soft: #dbeafe; + --accent-text: #ffffff; + --danger: #b91c1c; + --error: #b91c1c; + --success: #166534; + --shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + font-family: + Arial, + Helvetica, + sans-serif; + background: var(--background); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.app { + min-height: 100vh; + background: var(--background); + color: var(--text); +} + +.appDark { + color-scheme: dark; + --background: #0f172a; + --surface: #111827; + --surface-strong: #1f2937; + --text: #e5e7eb; + --muted: #9ca3af; + --border: #374151; + --accent-text: #0f172a; + --danger: #f87171; + --error: #fca5a5; + --success: #86efac; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.topBar, +.adminTopBar { + position: relative; + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 22px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.brandBlock { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.brandLogoFrame { + width: 46px; + height: 46px; + display: grid; + place-items: center; + flex: 0 0 auto; + overflow: hidden; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 12px; +} + +.brandLogo { + width: 100%; + height: 100%; + object-fit: contain; +} + +.brandText { + min-width: 0; + display: grid; + gap: 3px; +} + +.title { + font-size: 18px; + font-weight: 700; +} + +.subtitle { + color: var(--muted); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profileMenu { + position: relative; +} + +.profileButton { + width: 44px; + height: 44px; + display: grid; + place-items: center; + color: var(--accent-text); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 999px; + cursor: pointer; + font-size: 14px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.profileDropdown { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 300; + width: 280px; + display: grid; + gap: 8px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.profileDropdownHeader { + padding: 8px 8px 12px; + border-bottom: 1px solid var(--border); +} + +.profileDropdownName { + font-weight: 700; +} + +.profileDropdownEmail { + margin-top: 3px; + color: var(--muted); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; +} + +.profileDropdownRole { + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.menuButton { + min-height: 40px; + width: 100%; + padding: 10px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + text-align: left; +} + +.menuButton:hover { + border-color: var(--accent); +} + +.menuButtonLink { + display: block; +} + +.menuButtonDanger { + color: var(--danger); +} + +.messageBar { + padding: 18px 28px 0; +} + +.dashboardWorkspace { + display: grid; + gap: 18px; + padding: 28px; +} + +.editToolbar { + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + background: var(--surface); + border: 1px dashed var(--accent); + border-radius: 16px; + box-shadow: var(--shadow); +} + +.editToolbar p { + margin: 4px 0 0; +} + +.addWidgetMenu { + position: relative; + flex: 0 0 auto; +} + +.addWidgetDropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 320; + width: 240px; + display: grid; + gap: 8px; + padding: 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.addWidgetEmpty { + margin: 0; + padding: 8px; +} + +.emptyDashboard { + min-height: 220px; + display: grid; + place-items: center; + gap: 8px; + padding: 28px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border); + border-radius: 16px; +} + +.emptyDashboard h2, +.emptyDashboard p { + margin: 0; +} + +.widgetGridShell { + min-height: calc(100vh - 130px); +} + +.widgetGrid { + position: relative; + min-height: 400px; +} + +.react-grid-item { + overflow: visible; + transition: + transform 160ms ease, + width 160ms ease, + height 160ms ease; +} + +.react-grid-item.react-grid-placeholder { + background: var(--accent); + border-radius: 16px; + opacity: 0.18; +} + +.react-grid-item.react-draggable-dragging, +.react-grid-item.resizing { + z-index: 200; + transition: none; +} + +.gridItemMenuOpen { + z-index: 250 !important; +} + +.react-grid-item > .react-resizable-handle { + z-index: 40; + width: 30px; + height: 30px; + opacity: 0.95; +} + +.react-grid-item > .react-resizable-handle::after { + width: 12px; + height: 12px; + right: 7px; + bottom: 7px; + border-right: 3px solid var(--accent); + border-bottom: 3px solid var(--accent); +} + +.widgetCard { + position: relative; + container-type: size; + height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: visible; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow); +} + +.widgetHeader { + position: relative; + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} + +.widgetTitle { + min-width: 0; + flex: 1; +} + +.widgetTitle h2 { + margin: 0; + font-size: clamp(13px, 4cqw, 18px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.widgetTitleInput { + width: 100%; + height: 32px; + padding: 0 8px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 9px; + outline: none; + font-weight: 700; + font-size: clamp(12px, 3.5cqw, 14px); +} + +.widgetTitleInput:focus { + border-color: var(--accent); +} + +.widgetDragHandle { + height: 30px; + padding: 0 9px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 9px; + cursor: grab; + white-space: nowrap; + font-size: 12px; + user-select: none; + touch-action: none; +} + +.widgetDragHandle:active { + cursor: grabbing; +} + +.widgetMenu { + position: relative; + flex: 0 0 auto; +} + +.widgetMenuButton { + height: 30px; + padding: 0 9px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 9px; + cursor: pointer; + white-space: nowrap; + font-size: 12px; +} + +.widgetMenuButton:hover, +.widgetDragHandle:hover { + border-color: var(--accent); +} + +.widgetDropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 350; + width: 190px; + display: grid; + gap: 7px; + padding: 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.widgetDropdownButton { + min-height: 36px; + width: 100%; + padding: 0 10px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 9px; + cursor: pointer; + text-align: left; +} + +.widgetDropdownButton:hover { + border-color: var(--accent); +} + +.widgetDropdownDanger { + color: var(--danger); +} + +.widgetContent { + min-height: 0; + overflow: auto; + padding: 10px; + border-radius: 0 0 16px 16px; + font-size: clamp(12px, 2.2cqw, 14px); +} + +.favoriteTile { + min-width: 0; + min-height: 44px; + display: flex; + align-items: center; + flex: 1; + gap: 10px; + padding: 8px 10px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 12px; +} + +.favoriteTile:hover { + border-color: var(--accent); +} + +.favoriteTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.favoriteIcon { + position: relative; + width: clamp(26px, 8cqw, 36px); + height: clamp(26px, 8cqw, 36px); + display: grid; + place-items: center; + flex: 0 0 auto; + overflow: hidden; + background: transparent; + border: 0; + border-radius: 10px; +} + +.favoriteIconFallback { + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.favoriteIconImage { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + background: transparent; +} + +.noteCard { + display: grid; + gap: 8px; + padding: 10px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 12px; +} + +.noteHeader { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.noteTitleInput { + width: 100%; + height: 34px; + padding: 0 9px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 9px; + font-weight: 700; + outline: none; +} + +.noteTitleInput:focus { + border-color: var(--accent); +} + +.noteTextarea { + width: 100%; + min-height: 90px; + resize: vertical; + padding: 10px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + outline: none; +} + +.noteTextarea:focus { + border-color: var(--accent); +} + +.noteDeleteButton, +.todoDeleteButton { + min-height: 32px; + padding: 0 9px; + color: var(--danger); + background: transparent; + border: 1px solid var(--danger); + border-radius: 9px; + cursor: pointer; + white-space: nowrap; + font-size: 12px; +} + +.noteEmptyText { + margin: 0; +} + +.todoEditor { + display: grid; + gap: 8px; +} + +.todoTask { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.todoCheckbox { + width: 18px; + height: 18px; +} + +.todoTaskInput { + width: 100%; + min-height: 34px; + padding: 0 9px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 9px; + outline: none; +} + +.todoTaskInput:focus { + border-color: var(--accent); +} + +.todoTaskDone { + color: var(--muted); + text-decoration: line-through; +} + +.todoAddForm { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.searchWidgetForm { + height: 100%; + display: grid; + grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.input, +.searchInput, +.select { + width: 100%; + height: 40px; + padding: 0 10px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + outline: none; +} + +.input:focus, +.searchInput:focus, +.select:focus { + border-color: var(--accent); +} + +.fileInput { + width: 100%; + min-height: 44px; + padding: 9px 12px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; +} + +.fileInput:focus { + outline: none; + border-color: var(--accent); +} + +.colorInput { + width: 100%; + height: 44px; + padding: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; +} + +.colorInput:focus { + outline: none; + border-color: var(--accent); +} + +.themeColorGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.button { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + color: var(--accent-text); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 10px; + cursor: pointer; + white-space: nowrap; +} + +.button:hover { + filter: brightness(0.95); +} + +.buttonSecondary { + color: var(--text); + background: var(--surface-strong); + border-color: var(--border); +} + +.smallDangerButton { + height: 34px; + padding: 0 9px; + color: var(--danger); + background: transparent; + border: 1px solid var(--danger); + border-radius: 9px; + cursor: pointer; + white-space: nowrap; +} + +.calendarHeader { + display: grid; + grid-template-columns: 72px 1fr 72px; + gap: 6px; + align-items: center; + margin-bottom: 10px; +} + +.calendarNavButton, +.calendarMonthButton { + height: 34px; + padding: 0 8px; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; +} + +.calendarMonthButton { + font-weight: 700; + text-transform: capitalize; +} + +.calendarWeekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 4px; +} + +.calendarWeekday { + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.calendarDay { + position: relative; + min-height: 38px; + padding: 5px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 9px; +} + +.calendarDayMuted { + opacity: 0.45; +} + +.calendarDayToday { + border-color: var(--accent); +} + +.calendarDayWithEvents { + background: var(--accent-soft); + border-color: var(--accent); +} + +.calendarDayNumber { + font-size: 12px; + font-weight: 700; +} + +.calendarEventCount { + position: absolute; + right: 4px; + bottom: 4px; + min-width: 18px; + height: 18px; + display: grid; + place-items: center; + padding: 0 4px; + color: var(--accent-text); + background: var(--accent); + border-radius: 999px; + font-size: 10px; + font-weight: 700; +} + +.calendarTooltip { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + z-index: 20; + width: 260px; + display: none; + padding: 10px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); +} + +.calendarDay:hover .calendarTooltip { + display: grid; + gap: 8px; +} + +.calendarTooltipItem { + display: grid; + grid-template-columns: 48px 1fr; + gap: 8px; + font-size: 13px; +} + +.calendarTooltipTime { + color: var(--muted); +} + +.calendarTooltipMore { + color: var(--muted); + font-size: 12px; +} + +.calendarStatus { + margin-top: 10px; +} + +.nextEventsBlock { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.nextEventsBlock h3 { + margin: 0 0 10px; + font-size: 15px; +} + +.eventList { + display: grid; + gap: 8px; +} + +.eventItem { + padding: 9px 10px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 10px; +} + +.eventDate { + margin-bottom: 4px; + color: var(--muted); + font-size: 12px; +} + +.eventTitle { + font-weight: 700; +} + +.eventLocation { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.adminTopActions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.adminShell { + width: min(1180px, 100%); + display: grid; + gap: 20px; + margin: 0 auto; + padding: 28px; +} + +.adminPanel { + display: grid; + gap: 16px; + padding: 22px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow); +} + +.adminPanel h1 { + margin: 0; + font-size: 24px; +} + +.adminPanel p { + margin: 0; +} + +.logoUploadLayout { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 18px; + align-items: start; + padding: 16px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 14px; +} + +.logoPreviewBox { + width: 160px; + height: 160px; + display: grid; + place-items: center; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; +} + +.logoPreviewImage { + width: 100%; + height: 100%; + object-fit: contain; +} + +.logoUploadForm { + display: grid; + gap: 12px; +} + +.adminCardGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.adminCard { + display: grid; + gap: 8px; + min-height: 150px; + padding: 18px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 14px; +} + +.adminCard:hover { + border-color: var(--accent); +} + +.adminCard h2 { + margin: 0; + font-size: 18px; +} + +.adminCard p { + color: var(--muted); + line-height: 1.4; +} + +.adminCardDisabled { + opacity: 0.6; +} + +.adminForm { + display: grid; + gap: 14px; + max-width: 760px; +} + +.adminUserCreateForm { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(180px, 1fr) minmax(180px, 1fr) 150px auto; + gap: 12px; + align-items: end; +} + +.adminInlineButton { + width: fit-content; +} + +.userList { + display: grid; + gap: 8px; +} + +.userListItem { + padding: 10px 12px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 10px; +} + +.userListEmail { + font-weight: 700; +} + +.userListMeta { + margin-top: 3px; + color: var(--muted); + font-size: 13px; +} + +.muted { + color: var(--muted); +} + +.errorText { + color: var(--error); +} + +.successText { + color: var(--success); +} + +.loginShell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 20px; +} + +.loginCard { + width: min(420px, 100%); + display: grid; + gap: 14px; + padding: 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow); +} + +.loginCard h1 { + margin: 0; + font-size: 24px; +} + +.fieldLabel { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +@container (max-width: 260px) { + .widgetHeader { + gap: 5px; + padding: 6px; + } + + .widgetDragHandle, + .widgetMenuButton { + height: 28px; + padding: 0 6px; + font-size: 11px; + } + + .widgetContent { + padding: 7px; + } + + .searchWidgetForm, + .todoAddForm { + grid-template-columns: 1fr; + } + + .favoriteItem { + align-items: stretch; + flex-direction: column; + } + + .calendarHeader { + grid-template-columns: 1fr; + } + + .calendarNavButton, + .calendarMonthButton { + height: 30px; + font-size: 11px; + } + + .noteHeader, + .todoTask { + grid-template-columns: 1fr; + } +} + +@container (max-height: 170px) { + .widgetContent { + padding: 6px; + } + + .widgetHeader { + min-height: 34px; + padding: 5px 7px; + } + + .widgetTitle h2 { + font-size: 12px; + } + + .widgetDragHandle, + .widgetMenuButton { + height: 26px; + font-size: 10px; + } + + .favoriteTile { + min-height: 34px; + padding: 6px 8px; + } + + .calendarWeekdays, + .calendarGrid, + .nextEventsBlock, + .noteboardActions { + display: none; + } + + .searchWidgetForm { + grid-template-columns: 1fr; + } + + .noteCard { + padding: 7px; + } + + .noteTextarea { + min-height: 50px; + } +} + +@media (max-width: 760px) { + .topBar, + .adminTopBar { + align-items: stretch; + } + + .topBar { + min-height: auto; + } + + .adminTopBar { + flex-direction: column; + } + + .brandLogoFrame { + width: 42px; + height: 42px; + } + + .adminTopActions { + width: 100%; + } + + .adminTopActions .button { + flex: 1; + } + + .profileDropdown { + right: 0; + width: min(280px, calc(100vw - 32px)); + } + + .dashboardWorkspace { + padding: 16px; + } + + .editToolbar { + align-items: stretch; + flex-direction: column; + } + + .addWidgetMenu { + width: 100%; + } + + .addWidgetMenu .button { + width: 100%; + } + + .addWidgetDropdown { + left: 0; + right: auto; + width: 100%; + } + + .searchWidgetForm { + grid-template-columns: 1fr; + height: auto; + } + + .favoriteItem { + align-items: stretch; + flex-direction: column; + } + + .messageBar { + padding: 16px 16px 0; + } + + .adminShell { + padding: 16px; + } + + .logoUploadLayout { + grid-template-columns: 1fr; + } + + .logoPreviewBox { + width: 100%; + height: 180px; + } + + .adminUserCreateForm { + grid-template-columns: 1fr; + } + + .themeColorGrid { + grid-template-columns: 1fr; + } + + .calendarTooltip { + right: auto; + left: 0; + width: 230px; + } +} + +.singleNoteWidget { + height: 100%; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 10px; +} + +.singleNoteTextarea { + height: 100%; + min-height: 0; + resize: none; +} + +.singleNoteWidget .todoEditor { + min-height: 0; + overflow: auto; +} + +.singleNoteWidget .noteDeleteButton { + justify-self: start; +} + +.singleNoteWidget .todoTask { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +@container (max-width: 240px) { + .singleNoteWidget .todoTask { + grid-template-columns: auto minmax(0, 1fr); + } + + .singleNoteWidget .todoDeleteButton { + grid-column: 1 / -1; + } +} + +@container (max-height: 160px) { + .singleNoteWidget { + gap: 6px; + } + + .singleNoteWidget .noteDeleteButton, + .singleNoteWidget .todoDeleteButton, + .singleNoteWidget .todoAddForm { + display: none; + } +} + +.singleNoteWidget { + height: 100%; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 10px; +} + +.singleNoteTextarea { + height: 100%; + min-height: 0; + resize: none; +} + +.singleNoteWidget .todoEditor { + min-height: 0; + overflow: auto; +} + +.singleNoteWidget .noteDeleteButton { + justify-self: start; +} + +.singleNoteWidget .todoTask { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +@container (max-width: 240px) { + .singleNoteWidget .todoTask { + grid-template-columns: auto minmax(0, 1fr); + } + + .singleNoteWidget .todoDeleteButton { + grid-column: 1 / -1; + } +} + +@container (max-height: 160px) { + .singleNoteWidget { + gap: 6px; + } + + .singleNoteWidget .noteDeleteButton, + .singleNoteWidget .todoDeleteButton, + .singleNoteWidget .todoAddForm { + display: none; + } +} + +.todoAddForm { + grid-template-columns: minmax(0, 1fr) 40px; +} + +.todoAddForm .button { + width: 40px; + min-width: 40px; + height: 40px; + min-height: 40px; + padding: 0; + font-size: 0; + border-radius: 10px; +} + +.todoAddForm .button::before { + content: "+"; + font-size: 22px; + font-weight: 700; + line-height: 1; +} + +@container (max-width: 240px) { + .todoAddForm { + grid-template-columns: minmax(0, 1fr) 40px; + } +} + +.widgetCard-search .widgetTitle { + display: none; +} + +.widgetCard-search .widgetHeader { + justify-content: space-between; +} + +.widgetCard-search:not(:has(.widgetDragHandle)) { + grid-template-rows: minmax(0, 1fr); +} + +.widgetCard-search:not(:has(.widgetDragHandle)) .widgetHeader { + display: none; +} + +.widgetCard-search:not(:has(.widgetDragHandle)) .widgetContent { + border-radius: 16px; +} + +.app { + position: relative; + isolation: isolate; + --dashboard-background-image: none; + --dashboard-background-opacity: 0; +} + +.app::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: var(--dashboard-background-image); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + opacity: var(--dashboard-background-opacity); +} + +.app > * { + position: relative; + z-index: 1; +} + +.settingsSubPanel { + display: grid; + gap: 14px; + padding: 16px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 14px; +} + +.settingsSubPanel h2 { + margin: 0; + font-size: 18px; +} + +.rangeInput { + width: 100%; + accent-color: var(--accent); +} + +.backgroundPreviewBox { + position: relative; + min-height: 180px; + display: grid; + place-items: center; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; +} + +.backgroundPreviewImage { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.backgroundPreviewOverlay { + position: absolute; + inset: 0; + background: var(--surface); + pointer-events: none; +} + +.settingsButtonRow { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.calendarSourcePanel { + margin-bottom: 12px; + padding: 10px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 12px; +} + +.calendarSourcePanel summary { + cursor: pointer; + font-weight: 700; +} + +.calendarSourceForm { + display: grid; + gap: 10px; + margin-top: 10px; +} + +.calendarSourceForm .settingsButtonRow { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.topBar, +.adminTopBar { + z-index: 1000; +} + +.profileMenu { + z-index: 1100; +} + +.profileDropdown { + z-index: 1200; +} + +.dashboardWorkspace, +.widgetGridShell, +.emptyDashboard { + z-index: 1; +} + +.app::before { + z-index: 0; +} + +.app > * { + position: relative; + z-index: 1; +} + +.app > .topBar, +.app > .adminTopBar { + position: relative; + z-index: 5000; +} + +.topBar .profileMenu { + position: relative; + z-index: 5100; +} + +.topBar .profileDropdown { + position: absolute; + z-index: 5200; + pointer-events: auto; +} + +.dashboardWorkspace, +.widgetGridShell, +.widgetGrid, +.emptyDashboard, +.react-grid-layout, +.react-grid-item { + z-index: auto; +} + +.react-grid-item.react-draggable-dragging, +.react-grid-item.resizing { + z-index: 200; +} + +.gridItemMenuOpen { + z-index: 250 !important; +} + +.widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) auto !important; + align-items: center; +} + +.widgetCard-search .searchWidgetForm .select, +.widgetCard-search .searchWidgetForm .searchInput, +.widgetCard-search .searchWidgetForm .button { + min-width: 0; + height: 40px; +} + +.widgetCard-search .searchWidgetForm .button { + white-space: nowrap; +} + +@container (max-width: 260px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(64px, 90px) minmax(0, 1fr) 40px !important; + } + + .widgetCard-search .searchWidgetForm .button { + width: 40px; + min-width: 40px; + padding: 0; + font-size: 0; + } + + .widgetCard-search .searchWidgetForm .button::before { + content: ">"; + font-size: 18px; + font-weight: 700; + line-height: 1; + } +} + +@container (max-height: 170px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(64px, 90px) minmax(0, 1fr) 40px !important; + } +} + +@media (max-width: 760px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) auto !important; + } +} + +.widgetCard-search .searchWidgetForm .button { + position: relative; + width: 44px; + min-width: 44px; + height: 40px; + min-height: 40px; + padding: 0; + font-size: 0; + overflow: hidden; +} + +.widgetCard-search .searchWidgetForm .button::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-radius: 999px; + transform: translate(-60%, -60%); +} + +.widgetCard-search .searchWidgetForm .button::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 9px; + height: 2px; + background: currentColor; + border-radius: 999px; + transform: translate(1px, 6px) rotate(45deg); + transform-origin: left center; +} + +.widgetCard-search .searchWidgetForm { + display: grid; + grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) 44px !important; + align-items: center !important; + column-gap: 8px; +} + +.widgetCard-search .searchWidgetForm .select, +.widgetCard-search .searchWidgetForm .searchInput, +.widgetCard-search .searchWidgetForm .button { + height: 40px; + min-height: 40px; + margin: 0; + align-self: center; + box-sizing: border-box; +} + +.widgetCard-search .searchWidgetForm .button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + min-width: 44px; + padding: 0; + line-height: 1; + vertical-align: middle; +} + +.widgetCard-search .searchWidgetForm .button::before, +.widgetCard-search .searchWidgetForm .button::after { + top: 50%; +} + +@container (max-width: 260px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(64px, 90px) minmax(0, 1fr) 44px !important; + } +} + +@media (max-width: 760px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) 44px !important; + } +} + +.widgetCard-search { + background: transparent; + border: 0; + box-shadow: none; + border-radius: 0; +} + +.widgetCard-search .widgetContent { + height: 100%; + display: grid; + align-items: center; + padding: 0; + overflow: visible; + border-radius: 0; +} + +.widgetCard-search .searchWidgetForm { + height: 48px; + min-height: 48px; + display: grid; + grid-template-columns: 140px minmax(0, 1fr) 48px !important; + align-items: center !important; + gap: 8px; + padding: 0; + margin: 0; +} + +.widgetCard-search .searchWidgetForm .select, +.widgetCard-search .searchWidgetForm .searchInput, +.widgetCard-search .searchWidgetForm .button { + height: 42px; + min-height: 42px; + margin: 0; + align-self: center; + border-radius: 11px; +} + +.widgetCard-search .searchWidgetForm .button { + width: 48px; + min-width: 48px; + padding: 0; +} + +.widgetCard-search .searchWidgetForm .button::before { + width: 13px; + height: 13px; + transform: translate(-60%, -60%); +} + +.widgetCard-search .searchWidgetForm .button::after { + width: 9px; + height: 2px; + transform: translate(1px, 6px) rotate(45deg); +} + +@container (max-width: 320px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: 105px minmax(0, 1fr) 44px !important; + } + + .widgetCard-search .searchWidgetForm .button { + width: 44px; + min-width: 44px; + } +} + +@container (max-width: 240px) { + .widgetCard-search .searchWidgetForm { + grid-template-columns: 88px minmax(0, 1fr) 42px !important; + gap: 6px; + } + + .widgetCard-search .searchWidgetForm .button { + width: 42px; + min-width: 42px; + } +} + +.widgetGridShell, +.widgetGrid, +.react-grid-layout, +.react-grid-item, +.react-grid-item > div, +.widgetCard-calendar, +.widgetCard-calendar .widgetContent, +.widgetCard-calendar .calendarGrid, +.widgetCard-calendar .calendarDay { + overflow: visible !important; +} + +.react-grid-item:has(.widgetCard-calendar:hover), +.react-grid-item:has(.calendarDay:hover), +.react-grid-item:hover { + z-index: 900 !important; +} + +.widgetCard-calendar .calendarTooltip { + z-index: 6000; + pointer-events: none; +} + +.app { + position: relative; + isolation: isolate; + background-color: var(--background); +} + +.app::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: var(--dashboard-background-image); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + opacity: var(--dashboard-background-opacity); +} + +.app > * { + position: relative; + z-index: 1; +} + +.app > .topBar, +.app > .adminTopBar { + z-index: 5000; +} + +.topBar .profileMenu, +.adminTopBar .profileMenu { + z-index: 5100; +} + +.topBar .profileDropdown, +.adminTopBar .profileDropdown { + z-index: 5200; +} + +.favoritesWidget { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; + overflow: hidden; +} + +.favoriteTileList { + min-height: 0; + display: grid; + align-content: start; + gap: 10px; + overflow: auto; + padding-right: 4px; +} + +.favoriteTile { + min-height: 54px; + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 8px 10px; + color: var(--text); + text-decoration: none; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 12px; +} + +.favoriteTile:hover { + border-color: var(--accent); +} + +.favoriteIconFrame { + width: 36px; + height: 36px; + display: grid; + place-items: center; + overflow: hidden; + background: transparent; + border-radius: 10px; +} + +.favoriteIcon { + width: 100%; + height: 100%; + object-fit: contain; +} + +.favoriteIconPlaceholder { + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: var(--accent-text); + background: var(--accent); + border-radius: 10px; + font-size: 12px; + font-weight: 800; +} + +.favoriteTitle { + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.favoriteRemoveButton { + padding: 7px 10px; + color: #ff6b7a; + background: transparent; + border: 1px solid #ff6b7a; + border-radius: 9px; + cursor: pointer; +} + +.favoriteRemoveButton:hover { + background: rgba(255, 107, 122, 0.12); +} + +.favoriteAddForm { + display: grid; + gap: 9px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.favoriteAddButton { + width: 100%; +} + +@container (max-width: 260px) { + .favoriteTile { + grid-template-columns: 34px minmax(0, 1fr); + } + + .favoriteRemoveButton { + grid-column: 1 / -1; + } + + .favoriteIconFrame { + width: 30px; + height: 30px; + } +} + +.topEditModeButton { + width: 42px; + height: 42px; + display: inline-grid; + place-items: center; + flex: 0 0 auto; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + box-shadow: var(--shadow); +} + +.topEditModeButton:hover { + border-color: var(--accent); +} + +.topEditModeButtonActive { + color: var(--accent-text); + background: var(--accent); + border-color: var(--accent); +} + +.topEditModeIcon { + display: grid; + place-items: center; + font-size: 19px; + line-height: 1; +} + +.topBarActions, +.adminTopActions { + align-items: center; +} + +/* Falls der bisherige Bearbeiten-Button in der Toolbar separat angezeigt wird, + bleibt die Funktion oben rechts maßgeblich. */ +.editToolbar .button:first-child { + display: none; +} + +.topBar { + position: relative; +} + +.topBar .topEditModeButton { + position: absolute; + top: 50%; + right: 74px; + z-index: 5300; + transform: translateY(-50%); +} + +.topEditModeButton { + width: 42px; + height: 42px; + display: inline-grid; + place-items: center; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + box-shadow: var(--shadow); +} + +.topEditModeButton:hover { + border-color: var(--accent); +} + +.topEditModeButtonActive { + color: var(--accent-text); + background: var(--accent); + border-color: var(--accent); +} + +.lockIcon { + position: relative; + width: 21px; + height: 24px; + display: block; +} + +.lockIconBody { + position: absolute; + left: 3px; + bottom: 1px; + width: 15px; + height: 13px; + border: 2px solid currentColor; + border-radius: 4px; +} + +.lockIconBody::after { + content: ""; + position: absolute; + left: 50%; + top: 4px; + width: 2px; + height: 5px; + background: currentColor; + border-radius: 999px; + transform: translateX(-50%); +} + +.lockIconShackle { + position: absolute; + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-bottom: 0; + border-radius: 10px 10px 0 0; +} + +.lockIconClosed .lockIconShackle { + left: 4px; + top: 1px; +} + +.lockIconOpen .lockIconShackle { + left: 8px; + top: 0; + transform: rotate(28deg); + transform-origin: left bottom; +} + +@media (max-width: 760px) { + .topBar .topEditModeButton { + right: 66px; + } + + .topEditModeButton { + width: 38px; + height: 38px; + } +} + +.logoSettingsRow { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.logoSettingsPreview { + width: 96px; + height: 96px; + display: grid; + place-items: center; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; +} + +.logoSettingsPreview img { + width: 78px; + height: 78px; + object-fit: contain; +} + +.logoSettingsFields { + min-width: 0; + display: grid; + gap: 12px; +} + +@media (max-width: 760px) { + .logoSettingsRow { + grid-template-columns: 1fr; + } +} + +.dashboardTabsBar { + margin-bottom: 14px; +} + +.dashboardTabsList { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.dashboardTab { + min-height: 38px; + padding: 0 14px; + color: var(--text); + background: color-mix(in srgb, var(--surface-strong) 88%, transparent); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + font-weight: 700; +} + +.dashboardTab:hover { + border-color: var(--accent); +} + +.dashboardTabActive { + color: var(--accent-text, #ffffff); + background: var(--accent); + border-color: var(--accent); +} + +.dashboardTabAdd { + width: 38px; + padding: 0; + display: grid; + place-items: center; + font-size: 22px; + line-height: 1; +} + +.widgetCard { + opacity: var(--widget-opacity, 1); +} + +.widgetOpacityControl { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; + padding: 10px 12px; + color: var(--text); + font-size: 13px; +} + +.widgetOpacityControl input { + grid-column: 1 / -1; + width: 100%; +} + +.widgetCard-clock:not(:has(.widgetMenu)) .widgetHeader { + display: none; +} + +.widgetCard-clock .widgetTitle { + display: none; +} + +.widgetCard-clock .widgetContent { + height: 100%; + min-height: 0; +} + +.dashboardTabEditor { + min-height: 38px; + display: inline-grid; + grid-template-columns: minmax(90px, 180px) 30px; + align-items: center; + overflow: hidden; + background: color-mix(in srgb, var(--surface-strong) 88%, transparent); + border: 1px solid var(--border); + border-radius: 999px; +} + +.dashboardTabEditorActive { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.dashboardTabInput { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 4px 0 14px; + color: var(--text); + background: transparent; + border: 0; + outline: 0; + font-weight: 700; +} + +.dashboardTabDelete { + width: 30px; + height: 30px; + display: grid; + place-items: center; + margin-right: 4px; + color: #ff6b7a; + background: transparent; + border: 0; + border-radius: 999px; + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.dashboardTabDelete:hover:not(:disabled) { + background: rgba(255, 107, 122, 0.12); +} + +.dashboardTabDelete:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.widgetCard-clock { + overflow: hidden !important; +} + +.widgetCard-clock .widgetHeader { + display: none !important; +} + +.widgetCard-clock .widgetContent { + height: 100% !important; + min-height: 0 !important; + display: grid !important; + place-items: center !important; + padding: 0 !important; + overflow: hidden !important; + scrollbar-width: none !important; +} + +.widgetCard-clock .widgetContent::-webkit-scrollbar { + display: none !important; +} + +.widgetCard-clock .widgetContent > * { + width: 100% !important; + height: 100% !important; + min-height: 0 !important; + overflow: hidden !important; +} + +/* Custom-CSS-Feld */ +.customCssTextarea { + min-height: 220px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + line-height: 1.45; +} + +/* Widget-Transparenz: nur Bubble-Hintergrund, nicht Inhalt */ +.widgetCard { + position: relative; + isolation: isolate; + opacity: 1 !important; + background: transparent !important; +} + +.widgetCard::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-strong) 92%, transparent), + color-mix(in srgb, var(--surface) 92%, transparent) + ); + border-radius: inherit; + opacity: var(--widget-opacity, 1); +} + +.widgetCard > * { + position: relative; + z-index: 1; +} + +.widgetCard .favoriteTile, +.widgetCard .input, +.widgetCard .button, +.widgetCard .widgetMenuButton, +.widgetCard .widgetDropdown, +.widgetCard .widgetDragHandle, +.widgetCard textarea, +.widgetCard select { + opacity: 1 !important; +} + +/* Suche-Widget: keine eigene Widget-Bubble, nur Suchleiste anzeigen */ +.widgetCard-search { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + padding: 0 !important; +} + +.widgetCard-search::before { + display: none !important; +} + +.widgetCard-search .widgetHeader { + display: none !important; +} + +.widgetCard-search .widgetContent { + height: 100% !important; + min-height: 0 !important; + display: grid !important; + align-items: center !important; + padding: 0 !important; + overflow: visible !important; +} + +.widgetCard-search .searchWidgetForm { + width: 100%; + min-height: 0; + margin: 0; +} + +.calendarSourceList { + display: grid; + gap: 10px; +} + +.calendarSourceListItem { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; +} + +.calendarSourceColorDot { + width: 12px; + height: 12px; + display: inline-block; + flex: 0 0 auto; + border-radius: 999px; + box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 15%, transparent); +} + +.calendarSourceEditor { + display: grid; + gap: 12px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.calendarSourceCheckbox { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; +} + +.calendarSourceCheckbox small { + display: block; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 760px) { + .calendarSourceListItem { + grid-template-columns: auto minmax(0, 1fr); + } + + .calendarSourceListItem .settingsButtonRow { + grid-column: 1 / -1; + } +} + +/* Uhr-Widget: wirklich zentriert, ohne Scrollbar, ohne Titel/Innenbox */ +.widgetCard-clock { + container-type: size; + display: grid !important; + grid-template-rows: minmax(0, 1fr) !important; + padding: 0 !important; + overflow: hidden !important; +} + +.widgetCard-clock .widgetHeader { + display: none !important; +} + +.widgetCard-clock .widgetContent { + width: 100% !important; + height: 100% !important; + min-width: 0 !important; + min-height: 0 !important; + display: grid !important; + place-items: center !important; + padding: 0 !important; + margin: 0 !important; + overflow: hidden !important; + scrollbar-width: none !important; +} + +.widgetCard-clock .widgetContent::-webkit-scrollbar { + display: none !important; +} + +.widgetCard-clock .widgetContent > * { + width: 100% !important; + height: 100% !important; + min-width: 0 !important; + min-height: 0 !important; + overflow: hidden !important; +} + +/* Widget-Hinzufügen-Menü: linksbündig lassen, aber vertikal sauber ausrichten */ +.addWidgetDropdown { + padding-top: 10px !important; + padding-bottom: 10px !important; +} + +.addWidgetDropdown .widgetDropdownButton { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + text-align: left !important; + min-height: 36px !important; + line-height: 1.2 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +/* Widget-Menüs immer über anderen Widgets anzeigen */ +.react-grid-item { + overflow: visible !important; +} + +.react-grid-item.gridItemMenuOpen { + z-index: 99999 !important; +} + +.gridItemMenuOpen, +.gridItemMenuOpen .widgetCard, +.gridItemMenuOpen .widgetHeader, +.gridItemMenuOpen .widgetMenu { + overflow: visible !important; + z-index: 99999 !important; +} + +.widgetMenu { + position: relative !important; + z-index: 100000 !important; +} + +.widgetDropdown { + position: absolute !important; + z-index: 100001 !important; +} + +/* Suche-Widget: im normalen Modus ohne Rahmen/Header, im Bearbeitungsmodus aber mit Ziehen/Menü */ +.widgetCard-search { + overflow: visible !important; +} + +.widgetCard-search:has(.widgetDragHandle) { + display: grid !important; + grid-template-rows: auto minmax(0, 1fr) !important; + background: transparent !important; +} + +.widgetCard-search:has(.widgetDragHandle)::before { + display: block !important; + content: "" !important; +} + +.widgetCard-search:has(.widgetDragHandle) .widgetHeader { + display: flex !important; + background: transparent !important; + border-bottom: 1px solid var(--border) !important; +} + +.widgetCard-search:has(.widgetDragHandle) .widgetContent { + padding: 10px !important; +} + +.widgetCard-search:not(:has(.widgetDragHandle)) .widgetHeader { + display: none !important; +} + +/* Favoriten: Drag & Drop Sortierung */ +.favoriteSortableItem { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; + transition: + opacity 0.15s ease, + transform 0.15s ease, + background-color 0.15s ease; +} + +.favoriteSortableItemDragging { + opacity: 0.45; +} + +.favoriteSortableItemDragOver { + transform: translateY(2px); +} + +.favoriteSortableItemDragOver::before { + content: ""; + grid-column: 1 / -1; + height: 3px; + background: var(--accent); + border-radius: 999px; +} + +.favoriteDragHandle { + width: 34px; + min-width: 34px; + display: grid; + place-items: center; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + cursor: grab; + user-select: none; + font-weight: 800; + line-height: 1; +} + +.favoriteDragHandle:active { + cursor: grabbing; +} + +.favoriteSortableItem .favoriteTile { + min-width: 0; +} + +@media (max-width: 420px) { + .favoriteSortableItem { + grid-template-columns: minmax(0, 1fr) auto; + } + + .favoriteDragHandle { + grid-row: 1; + grid-column: 1 / -1; + width: 100%; + height: 28px; + } +} + +/* Favoriten: Einträge immer auf volle Widget-Breite ziehen */ +.favoritesWidget, +.favoriteTileList { + width: 100%; + min-width: 0; +} + +.favoriteTileList { + display: grid; + align-items: stretch; +} + +.favoriteSortableItem { + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) !important; + align-items: stretch; +} + +.favoriteSortableItem .favoriteTile { + width: 100%; + min-width: 0; +} + +/* Im Bearbeitungsmodus: Griff | Link | Entfernen */ +.favoriteSortableItem:has(.favoriteDragHandle) { + grid-template-columns: auto minmax(0, 1fr) auto !important; +} + +.favoriteSortableItem:has(.favoriteDragHandle) .favoriteTile { + grid-column: auto; +} + +/* Kompaktere Favoriten-Kacheln */ +.favoriteTileList { + gap: 6px !important; +} + +.favoriteSortableItem { + min-height: 38px !important; +} + +.favoriteSortableItem .favoriteTile, +.favoriteTile { + min-height: 38px !important; + height: 38px !important; + padding: 5px 10px !important; + border-radius: 10px !important; +} + +.favoriteIconFrame, +.favoriteIcon, +.favoriteIconImage, +.favoriteIconPlaceholder { + width: 28px !important; + height: 28px !important; + min-width: 28px !important; +} + +.favoriteTitle { + font-size: 13px !important; + line-height: 1.1 !important; +} + +.favoriteRemoveButton { + min-height: 38px !important; + height: 38px !important; + padding: 0 10px !important; +} + +.favoriteDragHandle { + min-height: 38px !important; + height: 38px !important; +} + +.favoriteAddForm, +.favoriteAddForm .input { + min-height: 36px !important; + height: 36px !important; +} + +.favoriteAddButton { + min-height: 36px !important; + height: 36px !important; +} + +/* Favoriten: Text responsiv kleiner bei schmalen Widgets */ +.widgetCard-favorites { + container-type: inline-size; +} + +.favoriteTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: clamp(10px, 4.2cqw, 13px) !important; +} + +@container (max-width: 240px) { + .favoriteTitle { + font-size: 11px !important; + } + + .favoriteSortableItem .favoriteTile, + .favoriteTile { + padding-left: 8px !important; + padding-right: 8px !important; + } + + .favoriteIconFrame, + .favoriteIcon, + .favoriteIconImage, + .favoriteIconPlaceholder { + width: 24px !important; + height: 24px !important; + min-width: 24px !important; + } +} + +@container (max-width: 180px) { + .favoriteTitle { + font-size: 10px !important; + } + + .favoriteSortableItem .favoriteTile, + .favoriteTile { + gap: 6px !important; + padding-left: 6px !important; + padding-right: 6px !important; + } + + .favoriteIconFrame, + .favoriteIcon, + .favoriteIconImage, + .favoriteIconPlaceholder { + width: 22px !important; + height: 22px !important; + min-width: 22px !important; + } +} + +/* Favoriten-Schriftgröße: nicht automatisch nach Widget-Breite skalieren, + sondern über Widget-Menü-Regler steuern. */ +.widgetCard-favorites .favoriteTitle, +.favoriteTitle { + font-size: calc(13px * var(--widget-font-scale, 1)) !important; + line-height: 1.15 !important; +} + +/* Frühere @container-Regeln für schmale Favoriten-Widgets neutralisieren. */ +@container (max-width: 240px) { + .widgetCard-favorites .favoriteTitle, + .favoriteTitle { + font-size: calc(13px * var(--widget-font-scale, 1)) !important; + } +} + +@container (max-width: 180px) { + .widgetCard-favorites .favoriteTitle, + .favoriteTitle { + font-size: calc(13px * var(--widget-font-scale, 1)) !important; + } +} + +.widgetFontSizeControl { + margin-top: 8px; +} + +/* Links/Favoriten: Abstand zwischen Icon und Text ca. 30% kleiner */ +.favoriteTile { + gap: 7px !important; +} + +.favoriteSortableItem .favoriteTile { + gap: 2px !important; +} + +/* Im Bearbeitungsmodus müssen alle Widgets Ziehen-Handle und Menü zeigen. */ +.widgetCard.widgetEditing { + display: grid !important; + grid-template-rows: auto minmax(0, 1fr) !important; + overflow: visible !important; +} + +.widgetCard.widgetEditing .widgetHeader { + display: flex !important; + align-items: center !important; + gap: 8px !important; + min-height: 34px !important; + padding: 8px 10px 6px !important; + overflow: visible !important; + background: transparent !important; + border-bottom: 1px solid var(--border) !important; + z-index: 20 !important; +} + +.widgetCard.widgetEditing .widgetDragHandle, +.widgetCard.widgetEditing .widgetMenu { + display: inline-flex !important; + visibility: visible !important; + opacity: 1 !important; +} + +.widgetCard.widgetEditing .widgetMenu { + margin-left: auto !important; +} + +/* Widgets ohne Titel im Normalmodus, z.B. Uhr/Suche, dürfen im Editmodus trotzdem Header haben. */ +.widgetCard-clock:not(.widgetEditing) .widgetHeader, +.widgetCard-search:not(.widgetEditing) .widgetHeader { + display: none !important; +} + +/* Alte Uhr-Regeln überschreiben: Uhr im Editmodus mit Header, sonst ohne Header. */ +.widgetCard-clock.widgetEditing { + grid-template-rows: auto minmax(0, 1fr) !important; +} + +.widgetCard-clock.widgetEditing .widgetHeader { + display: flex !important; +} + +.widgetCard-clock.widgetEditing .widgetContent { + height: 100% !important; + min-height: 0 !important; +} + +/* Dropdowns immer über anderen Widgets */ +.react-grid-item { + overflow: visible !important; +} + +.react-grid-item.gridItemMenuOpen { + z-index: 99999 !important; +} + +.gridItemMenuOpen, +.gridItemMenuOpen .widgetCard, +.gridItemMenuOpen .widgetHeader, +.gridItemMenuOpen .widgetMenu { + overflow: visible !important; + z-index: 99999 !important; +} + +.widgetMenu { + position: relative !important; + z-index: 100000 !important; +} + +.widgetDropdown { + position: absolute !important; + z-index: 100001 !important; +} + +/* Fix: Widget-Edit-Header sauber vertikal zentrieren */ +.widgetCard.widgetEditing .widgetHeader { + min-height: 44px !important; + height: 44px !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 8px !important; + padding: 6px 10px !important; + line-height: 1 !important; +} + +.widgetCard.widgetEditing .widgetTitle { + min-height: 32px !important; + height: 32px !important; + display: flex !important; + align-items: center !important; + flex: 1 1 auto !important; + min-width: 0 !important; +} + +.widgetCard.widgetEditing .widgetTitle h2 { + margin: 0 !important; + display: flex !important; + align-items: center !important; + min-height: 32px !important; + height: 32px !important; + line-height: 1.1 !important; +} + +.widgetCard.widgetEditing .widgetTitleInput { + height: 32px !important; + min-height: 32px !important; + margin: 0 !important; + line-height: 1 !important; + align-self: center !important; +} + +.widgetCard.widgetEditing .widgetDragHandle, +.widgetCard.widgetEditing .widgetMenuButton { + height: 32px !important; + min-height: 32px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + align-self: center !important; + margin: 0 !important; + padding: 0 10px !important; + line-height: 1 !important; + vertical-align: middle !important; +} + +.widgetCard.widgetEditing .widgetMenu { + height: 32px !important; + min-height: 32px !important; + display: inline-flex !important; + align-items: center !important; + align-self: center !important; + margin-left: auto !important; +} + +/* Kleine Widgets: Header bleibt zentriert, nur etwas kompakter */ +@container (max-height: 170px) { + .widgetCard.widgetEditing .widgetHeader { + min-height: 40px !important; + height: 40px !important; + padding: 4px 8px !important; + } + + .widgetCard.widgetEditing .widgetDragHandle, + .widgetCard.widgetEditing .widgetMenuButton, + .widgetCard.widgetEditing .widgetMenu, + .widgetCard.widgetEditing .widgetTitle, + .widgetCard.widgetEditing .widgetTitle h2, + .widgetCard.widgetEditing .widgetTitleInput { + height: 30px !important; + min-height: 30px !important; + } +} + +/* Kompakterer Edit-Header, damit Widgets niedriger skaliert werden können */ +.widgetCard.widgetEditing .widgetHeader { + min-height: 36px !important; + height: 36px !important; + padding: 3px 8px !important; + align-items: center !important; +} + +.widgetCard.widgetEditing .widgetTitle, +.widgetCard.widgetEditing .widgetTitle h2, +.widgetCard.widgetEditing .widgetTitleInput, +.widgetCard.widgetEditing .widgetDragHandle, +.widgetCard.widgetEditing .widgetMenu, +.widgetCard.widgetEditing .widgetMenuButton { + height: 30px !important; + min-height: 30px !important; + align-self: center !important; +} + +.widgetCard.widgetEditing .widgetDragHandle, +.widgetCard.widgetEditing .widgetMenuButton { + padding: 0 9px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + line-height: 1 !important; +} + +/* Uhr-Widget: Inhalt soll den Widget-Platz stärker ausnutzen, ohne Ziffern abzuschneiden */ +.widgetCard-clock:not(.widgetEditing) { + grid-template-rows: minmax(0, 1fr) !important; + padding: 0 !important; + overflow: hidden !important; +} + +.widgetCard-clock:not(.widgetEditing) .widgetHeader { + display: none !important; +} + +.widgetCard-clock:not(.widgetEditing) .widgetContent { + width: 100% !important; + height: 100% !important; + min-width: 0 !important; + min-height: 0 !important; + display: grid !important; + place-items: stretch !important; + padding: 0 !important; + margin: 0 !important; + overflow: hidden !important; +} + +.widgetCard-clock:not(.widgetEditing) .widgetContent > * { + width: 100% !important; + height: 100% !important; + min-width: 0 !important; + min-height: 0 !important; +} + +/* Dashboard-Tabs: kompakte Leiste direkt unter der oberen Titelleiste */ +.dashboardTabsBar { + margin: 0 !important; + padding: 6px 22px !important; + min-height: 42px !important; + display: flex !important; + align-items: center !important; + background: color-mix(in srgb, var(--surface) 82%, transparent) !important; + border-bottom: 1px solid var(--border) !important; + box-shadow: none !important; +} + +.dashboardTabsList { + width: 100% !important; + min-width: 0 !important; + display: flex !important; + flex-wrap: wrap !important; + align-items: center !important; + gap: 6px !important; +} + +.dashboardTab { + min-height: 28px !important; + height: 28px !important; + padding: 0 10px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 9px !important; + font-size: 12px !important; + font-weight: 700 !important; + line-height: 1 !important; +} + +.dashboardTabActive { + color: var(--accent-text, #ffffff) !important; + background: var(--accent) !important; + border-color: var(--accent) !important; +} + +.dashboardTabAdd { + width: 28px !important; + min-width: 28px !important; + padding: 0 !important; + font-size: 18px !important; +} + +/* Editierbare Tabs ebenfalls kleiner */ +.dashboardTabEditor { + min-height: 28px !important; + height: 28px !important; + grid-template-columns: minmax(70px, 140px) 24px !important; + border-radius: 9px !important; +} + +.dashboardTabInput { + height: 26px !important; + min-height: 26px !important; + padding: 0 4px 0 10px !important; + font-size: 12px !important; + line-height: 1 !important; +} + +.dashboardTabDelete { + width: 22px !important; + height: 22px !important; + margin-right: 3px !important; + font-size: 16px !important; +} + +/* Da die Tabs jetzt außerhalb liegen, braucht der Workspace oben weniger Luft */ +.dashboardWorkspace { + padding-top: 16px !important; +} + +@media (max-width: 760px) { + .dashboardTabsBar { + padding: 6px 16px !important; + } + + .dashboardWorkspace { + padding-top: 14px !important; + } +} + +/* Fix: Links/Favoriten-Widget komplett scrollbar machen, + damit auch Titel/URL/Logo-Felder erreichbar bleiben */ +.widgetCard-favorites { + min-height: 0 !important; + overflow: hidden !important; +} + +.widgetCard-favorites .widgetContent { + min-height: 0 !important; + height: 100% !important; + overflow-y: auto !important; + overflow-x: hidden !important; + padding: 10px !important; + scrollbar-width: thin; +} + +.widgetCard-favorites .favoritesWidget { + height: auto !important; + min-height: 100% !important; + display: grid !important; + grid-template-rows: auto auto !important; + gap: 12px !important; + overflow: visible !important; +} + +.widgetCard-favorites .favoriteTileList { + max-height: none !important; + min-height: 0 !important; + overflow: visible !important; + padding-right: 0 !important; +} + +.widgetCard-favorites .favoriteAddForm { + position: relative !important; + display: grid !important; + flex: 0 0 auto !important; + padding-bottom: 4px !important; +} + +/* Im Bearbeitungsmodus genug Platz lassen, damit das Formular nicht vom Resize-Griff verdeckt wird */ +.widgetCard-favorites.widgetEditing .widgetContent { + padding-bottom: 20px !important; +} + +/* Kalender: Tage mit Terminen nur umranden, nicht hinterlegen */ +.calendarDayWithEvents { + background: transparent !important; + border-color: var(--accent) !important; + box-shadow: inset 0 0 0 1px var(--accent) !important; +} + +.calendarDayWithEvents:hover { + background: color-mix(in srgb, var(--accent-soft) 18%, transparent) !important; +} + +/* Heute + Termin: weiterhin erkennbar, aber ohne starke Fläche */ +.calendarDayToday.calendarDayWithEvents { + background: transparent !important; + border-color: var(--accent) !important; + box-shadow: + inset 0 0 0 1px var(--accent), + 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent) !important; +} + +/* Event-Zähler lesbar halten */ +.calendarDayWithEvents .calendarEventCount { + color: #fff !important; + background: var(--accent) !important; +} + +/* ========================================================= + Compact Widget UI Pass + Ziel: weniger Platzverbrauch, Griffe/Menüs ohne Layout-Höhe + ========================================================= */ + +.widgetCard { + position: relative !important; +} + +.widgetCardMenuOpen { + z-index: 950 !important; +} + +.widgetGrid .gridItemMenuOpen { + z-index: 950 !important; +} + +.react-grid-item.react-draggable-dragging { + z-index: 980 !important; +} + +/* Header kompakter */ +.widgetHeader { + min-height: 26px !important; + padding: 4px 8px 2px !important; + gap: 6px !important; +} + +.widgetCardEditMode .widgetHeader { + padding-left: 34px !important; + padding-right: 36px !important; +} + +/* Titel kompakter */ +.widgetTitle { + min-height: 24px !important; +} + +.widgetTitle h2 { + margin: 0 !important; + font-size: calc(14px * var(--widget-font-scale, 1)) !important; + line-height: 1.1 !important; +} + +.widgetTitleInput { + min-height: 24px !important; + height: 24px !important; + padding: 0 6px !important; + font-size: calc(13px * var(--widget-font-scale, 1)) !important; + line-height: 24px !important; +} + +/* Griff statt großer Ziehen-Button */ +.widgetDragHandle { + position: absolute !important; + top: 5px !important; + left: 7px !important; + z-index: 80 !important; + width: 20px !important; + height: 22px !important; + display: grid !important; + place-items: center !important; + padding: 0 !important; + color: var(--muted) !important; + background: transparent !important; + border: 0 !important; + border-radius: 8px !important; + cursor: grab !important; + user-select: none !important; + touch-action: none !important; +} + +.widgetDragHandle:hover { + background: color-mix(in srgb, var(--accent-soft) 38%, transparent) !important; + color: var(--accent) !important; +} + +.widgetDragHandle:active { + cursor: grabbing !important; +} + +.widgetDragGrip { + width: 13px !important; + height: 17px !important; + display: block !important; + opacity: 0.72 !important; + background-image: radial-gradient(currentColor 1.3px, transparent 1.4px) !important; + background-size: 6px 6px !important; + background-position: 0 0 !important; +} + +/* Menü aus dem Layoutfluss nehmen */ +.widgetMenu { + position: absolute !important; + top: 5px !important; + right: 7px !important; + z-index: 90 !important; + width: auto !important; + height: auto !important; +} + +.widgetMenuButton { + width: 24px !important; + min-width: 24px !important; + height: 24px !important; + min-height: 24px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + color: var(--muted) !important; + background: transparent !important; + border: 0 !important; + border-radius: 8px !important; + cursor: pointer !important; + font-size: 12px !important; + line-height: 1 !important; + letter-spacing: -1px !important; +} + +.widgetMenuButton:hover { + color: var(--accent) !important; + background: color-mix(in srgb, var(--accent-soft) 42%, transparent) !important; +} + +/* Dropdown kompakter und immer über Widgets */ +.widgetDropdown { + position: absolute !important; + top: 28px !important; + right: 0 !important; + z-index: 999 !important; + min-width: 190px !important; + padding: 6px !important; + gap: 4px !important; + border-radius: 12px !important; + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28) !important; +} + +.widgetDropdownButton { + min-height: 30px !important; + padding: 0 9px !important; + border-radius: 9px !important; + font-size: 12px !important; + line-height: 1.1 !important; +} + +/* Widget-Inhalt kompakter */ +.widgetContent { + padding: 7px 8px 8px !important; +} + +.widgetCardEditMode .widgetContent { + padding-top: 5px !important; +} + +/* Uhr bleibt ohne künstlichen Header-Platz */ +.widgetCard-clock .widgetHeader { + min-height: 0 !important; + height: 0 !important; + padding: 0 !important; +} + +.widgetCard-clock.widgetCardEditMode .widgetHeader { + min-height: 26px !important; + height: 26px !important; + padding: 0 !important; +} + +.widgetCard-clock.widgetCardEditMode .widgetContent { + padding-top: 2px !important; +} + +/* Controls innerhalb von Widgets schlanker */ +.widgetCard .button, +.widgetCard .buttonSecondary, +.widgetCard .input, +.widgetCard .select, +.widgetCard .searchInput { + min-height: 30px !important; + font-size: 12px !important; +} + +.widgetCard .button, +.widgetCard .buttonSecondary { + padding: 0 10px !important; + border-radius: 9px !important; +} + +.widgetCard .input, +.widgetCard .select, +.widgetCard .searchInput { + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-left: 9px !important; + padding-right: 9px !important; + border-radius: 9px !important; +} + +/* Labels/Form-Abstände in Widgets reduzieren */ +.widgetCard .fieldLabel, +.widgetCard .calendarSourceForm, +.widgetCard .favoriteAddForm, +.widgetCard .domainCheckForm, +.widgetCard .searchWidgetForm { + gap: 6px !important; +} + +.widgetCard .settingsButtonRow { + gap: 6px !important; +} + +/* Notiz-Markdown-Leiste kompakter */ +.widgetCard .noteMarkdownToolbar { + gap: 4px !important; + padding: 4px !important; +} + +.widgetCard .noteMarkdownToolbar button, +.widgetCard .markdownToolbarButton { + min-width: 28px !important; + min-height: 26px !important; + height: 26px !important; + padding: 0 7px !important; + font-size: 12px !important; +} + +/* Kalender etwas luftiger im Inhalt, aber weniger verschwenderisch */ +.widgetCard .calendarHeader { + gap: 5px !important; + margin-bottom: 6px !important; +} + +.widgetCard .calendarNavButton, +.widgetCard .calendarMonthButton { + min-height: 28px !important; + padding: 0 8px !important; + font-size: 12px !important; +} + +/* Transparenz-/Slider-Zeile im Menü kompakter */ +.widgetOpacityControl, +.widgetFontSizeControl { + gap: 5px !important; + padding: 6px 7px !important; + font-size: 12px !important; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..a302bac --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/note-widget.css b/src/app/note-widget.css new file mode 100644 index 0000000..a794a03 --- /dev/null +++ b/src/app/note-widget.css @@ -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; +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..4e29107 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,2370 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { CSSProperties, ReactNode } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import type { DashboardGridProps } from "@/components/DashboardGrid"; +import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout"; +import { sortLayoutForPosition } from "@/lib/dashboard-layout"; +import CalculatorWidget from "@/components/CalculatorWidget"; +import ClockWidget from "@/components/ClockWidget"; +import FavoritesWidget from "@/components/FavoritesWidget"; +import DomainCheckWidget from "@/components/DomainCheckWidget"; + +const DashboardGrid = dynamic(() => import("@/components/DashboardGrid"), { + ssr: false, + loading: () => ( +
+
+

Dashboard wird geladen

+

Widgets werden vorbereitet.

+
+
+ ) +}); + +type User = { + id: string; + email: string; + displayName: string | null; + role: "ADMIN" | "USER"; +}; + +type Settings = { + id: string; + userId: string; + darkMode: boolean; + calendarIcsUrl: string | null; + calendarMaxEvents: number; + calendarLookaheadDays: number; + dashboardTitle: string; + dashboardSubtitle: string | null; + logoUrl: string | null; + backgroundImageUrl: string | null; + backgroundImageOpacity: number; + primaryColor: string; + secondaryColor: string; + customCss: string; +}; + +type DashboardTab = { + id: string; + userId: string; + title: string; + position: number; +}; + +type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check"; + +type Widget = DashboardGridWidget & { + userId: string; + viewMode?: string; +}; + +type SearchProvider = { + id: string; + label: string; + urlTemplate: string; +}; + +type CalendarEvent = { + id: string; + title: string; + start: string; + end: string | null; + location: string | null; +}; + +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 CalendarWidgetCalendarConfig = { + sources: CalendarSource[]; + selectedSourceIds: string[]; + nextEventsCount: number; +}; + +type CalendarDay = { + key: string; + date: Date; + inCurrentMonth: boolean; + isToday: boolean; + events: CalendarEvent[]; +}; + +type NoteBoardItem = { + id: string; + userId: string; + type: "note" | string; + title: string; + content: string; + x: number; + y: number; + w: number; + h: number; + createdAt: string; + updatedAt: string; +}; + +const searchProviders: SearchProvider[] = [ + { + id: "google", + label: "Google", + urlTemplate: "https://www.google.com/search?q={query}" + }, + { + id: "bing", + label: "Bing", + urlTemplate: "https://www.bing.com/search?q={query}" + }, + { + id: "youtube", + label: "YouTube", + urlTemplate: "https://www.youtube.com/results?search_query={query}" + }, + { + id: "duckduckgo", + label: "DuckDuckGo", + urlTemplate: "https://duckduckgo.com/?q={query}" + } +]; + +const widgetCatalog: Array<{ + type: WidgetType; + title: string; +}> = [ + { + type: "favorites", + title: "Links/Favoriten" + }, + { + type: "note", + title: "Notiz" + }, + { + type: "clock", + title: "Uhr" + }, + { + type: "calculator", + title: "Taschenrechner" + }, + { + type: "search", + title: "Suche" + }, + { + type: "calendar", + title: "Kalender" + }, + { + type: "domain-check", + title: "Domainprüfung" + } +]; + +const weekdayLabels = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + +function dateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function formatEventDate(value: string): string { + return new Intl.DateTimeFormat("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit" + }).format(new Date(value)); +} + +function formatEventTime(value: string): string { + return new Intl.DateTimeFormat("de-DE", { + hour: "2-digit", + minute: "2-digit" + }).format(new Date(value)); +} + +function formatMonthLabel(value: Date): string { + return new Intl.DateTimeFormat("de-DE", { + month: "long", + year: "numeric" + }).format(value); +} + +function getUserInitials(user: User): string { + const source = user.displayName?.trim() || user.email.trim(); + + if (!source) { + return "U"; + } + + const parts = source.split(/[\s@._-]+/).filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + return source.slice(0, 2).toUpperCase(); +} + +function buildCalendarDays(monthDate: Date, eventsByDate: Map): CalendarDay[] { + const year = monthDate.getFullYear(); + const month = monthDate.getMonth(); + const firstDayOfMonth = new Date(year, month, 1); + const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7; + + const gridStart = new Date(firstDayOfMonth); + gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset); + gridStart.setHours(0, 0, 0, 0); + + const today = dateKey(new Date()); + const days: CalendarDay[] = []; + + for (let index = 0; index < 42; index += 1) { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + index); + + const key = dateKey(date); + + days.push({ + key, + date, + inCurrentMonth: date.getMonth() === month, + isToday: key === today, + events: eventsByDate.get(key) ?? [] + }); + } + + return days; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function buildThemeStyle(settings: Settings | null): CSSProperties { + const backgroundImageUrl = settings?.backgroundImageUrl?.trim() ?? ""; + const backgroundOpacity = Math.max(0, Math.min(100, settings?.backgroundImageOpacity ?? 0)) / 100; + const escapedBackgroundImageUrl = backgroundImageUrl.replace(/"/g, "\\\""); + + return { + "--accent": settings?.primaryColor ?? "#2563eb", + "--accent-soft": settings?.secondaryColor ?? "#dbeafe", + "--dashboard-background-image": backgroundImageUrl ? `url("${escapedBackgroundImageUrl}")` : "none", + "--dashboard-background-opacity": String(backgroundOpacity) + } as CSSProperties; +} + +function createClientId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + + + +async function parseJsonResponse(response: Response): Promise { + 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 DashboardPage() { + const saveLayoutTimeoutRef = useRef | null>(null); + const latestLayoutRef = useRef([]); + + const [user, setUser] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + const [loginEmail, setLoginEmail] = useState(""); + const [loginPassword, setLoginPassword] = useState(""); + const [loginError, setLoginError] = useState(null); + + const [widgets, setWidgets] = useState([]); + const [dashboardTabs, setDashboardTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [settings, setSettings] = useState(null); + const [notes, setNotes] = useState([]); + const [editMode, setEditMode] = useState(false); + const [noteError, setNoteError] = useState(null); + + const [query, setQuery] = useState(""); + const [providerId, setProviderId] = useState(searchProviders[0].id); + + const [calendarEventsByWidget, setCalendarEventsByWidget] = useState>({}); + const [calendarErrorsByWidget, setCalendarErrorsByWidget] = useState>({}); + const [calendarSources, setCalendarSources] = useState([]); + const [calendarSelectionsByWidget, setCalendarSelectionsByWidget] = useState>({}); + const [calendarNextEventsCountByWidget, setCalendarNextEventsCountByWidget] = useState>({}); + const [calendarMonth, setCalendarMonth] = useState(() => new Date()); + + const [profileMenuOpen, setProfileMenuOpen] = useState(false); + const [dashboardError, setDashboardError] = useState(null); + const [openWidgetMenuId, setOpenWidgetMenuId] = useState(null); + const [editingNoteWidgetId, setEditingNoteWidgetId] = useState(null); + const [addWidgetMenuOpen, setAddWidgetMenuOpen] = useState(false); + + const activeProvider = useMemo(() => { + return searchProviders.find((provider) => provider.id === providerId) ?? searchProviders[0]; + }, [providerId]); + + const sortedWidgets = useMemo(() => { + return [...widgets] + .filter((widget) => !activeTabId || widget.tabId === activeTabId) + .sort((a, b) => { + if (a.position !== b.position) { + return a.position - b.position; + } + + if (a.y !== b.y) { + return a.y - b.y; + } + + return a.x - b.x; + }); + }, [widgets, activeTabId]); + + const availableWidgetTypes = useMemo(() => widgetCatalog, []); + + const activeTab = useMemo(() => { + return dashboardTabs.find((tab) => tab.id === activeTabId) ?? dashboardTabs[0] ?? null; + }, [dashboardTabs, activeTabId]); + + const sortedNotes = useMemo(() => { + return [...notes].sort((a, b) => { + const first = new Date(a.createdAt).getTime(); + const second = new Date(b.createdAt).getTime(); + + return first - second; + }); + }, [notes]); + + const darkMode = settings?.darkMode ?? false; + const dashboardTitle = settings?.dashboardTitle?.trim() || "Personal Dashboard"; + const dashboardSubtitle = settings?.dashboardSubtitle?.trim() || user?.email || ""; + const logoUrl = settings?.logoUrl?.trim() || "/logo.svg"; + + function groupEventsByDate(events: CalendarEvent[]): Map { + const groupedEvents = new Map(); + + events.forEach((event) => { + const key = dateKey(new Date(event.start)); + const existingEvents = groupedEvents.get(key) ?? []; + + groupedEvents.set(key, [...existingEvents, event]); + }); + + return groupedEvents; + } + + useEffect(() => { + async function loadCurrentUser() { + try { + const response = await fetch("/api/auth/me", { + cache: "no-store" + }); + + const data = (await response.json()) as { + user: User | null; + }; + + setUser(data.user); + } finally { + setAuthChecked(true); + } + } + + void loadCurrentUser(); + }, []); + + useEffect(() => { + if (!user) { + return; + } + + void loadDashboardTabs(); + void loadDashboardData(); + }, [user]); + + useEffect(() => { + return () => { + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + }; + }, []); + + async function loadDashboardTabs() { + try { + const response = await fetch("/api/tabs", { + cache: "no-store" + }); + + const data = await parseJsonResponse<{ tabs: DashboardTab[] }>(response); + const sortedTabs = [...data.tabs].sort((a, b) => a.position - b.position); + + setDashboardTabs(sortedTabs); + + setActiveTabId((current) => { + if (current && sortedTabs.some((tab) => tab.id === current)) { + return current; + } + + if (user) { + const savedTabId = window.localStorage.getItem(`personal-dashboard-active-tab-${user.id}`); + + if (savedTabId && sortedTabs.some((tab) => tab.id === savedTabId)) { + return savedTabId; + } + } + + return sortedTabs[0]?.id ?? null; + }); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Tabs konnten nicht geladen werden."); + } + } + + function selectDashboardTab(tabId: string) { + setActiveTabId(tabId); + + if (user) { + window.localStorage.setItem(`personal-dashboard-active-tab-${user.id}`, tabId); + } + + setOpenWidgetMenuId(null); + setAddWidgetMenuOpen(false); + } + + async function createDashboardTab() { + setDashboardError(null); + + try { + const response = await fetch("/api/tabs", { + method: "POST" + }); + + const data = await parseJsonResponse<{ tab: DashboardTab }>(response); + + setDashboardTabs((current) => [...current, data.tab].sort((a, b) => a.position - b.position)); + selectDashboardTab(data.tab.id); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Tab konnte nicht erstellt werden."); + } + } + + async function updateDashboardTab(tabId: string, updates: Partial>) { + setDashboardError(null); + + try { + const response = await fetch(`/api/tabs/${encodeURIComponent(tabId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(updates) + }); + + const data = await parseJsonResponse<{ tab: DashboardTab }>(response); + + setDashboardTabs((current) => + current + .map((tab) => (tab.id === data.tab.id ? data.tab : tab)) + .sort((a, b) => a.position - b.position) + ); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Tab konnte nicht gespeichert werden."); + } + } + + async function deleteDashboardTab(tabId: string) { + if (dashboardTabs.length <= 1) { + setDashboardError("Der letzte Tab kann nicht gelöscht werden."); + return; + } + + setDashboardError(null); + + try { + const response = await fetch(`/api/tabs/${encodeURIComponent(tabId)}`, { + method: "DELETE" + }); + + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { error?: string } | null; + + throw new Error(data?.error ?? "Tab konnte nicht gelöscht werden."); + } + + const remainingTabs = dashboardTabs.filter((tab) => tab.id !== tabId); + + setDashboardTabs(remainingTabs); + + if (activeTabId === tabId) { + const nextTabId = remainingTabs[0]?.id ?? null; + + setActiveTabId(nextTabId); + + if (user && nextTabId) { + window.localStorage.setItem(`personal-dashboard-active-tab-${user.id}`, nextTabId); + } + } + + setWidgets((current) => current.filter((widget) => widget.tabId !== tabId)); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Tab konnte nicht gelöscht werden."); + } + } + + async function loadDashboardData() { + setDashboardError(null); + + try { + const [settingsResponse, widgetsResponse, notesResponse] = await Promise.all([ + fetch("/api/settings", { + cache: "no-store" + }), + fetch("/api/widgets", { + cache: "no-store" + }), + fetch("/api/notes", { + cache: "no-store" + }) + ]); + + const settingsData = await parseJsonResponse<{ settings: Settings }>(settingsResponse); + const widgetsData = await parseJsonResponse<{ widgets: Widget[] }>(widgetsResponse); + const notesData = await parseJsonResponse<{ notes: NoteBoardItem[] }>(notesResponse); + + const allSourcesResponse = await fetch("/api/calendar/source", { + cache: "no-store" + }); + const allSourcesData = await parseJsonResponse<{ sources: CalendarSource[] }>(allSourcesResponse); + + const calendarWidgets = widgetsData.widgets.filter((widget) => widget.type === "calendar"); + const calendarLoads = await Promise.all( + calendarWidgets.map(async (widget) => { + const [configResponse, eventsResponse] = await Promise.all([ + fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widget.id)}`, { + cache: "no-store" + }), + fetch(`/api/calendar?widgetId=${encodeURIComponent(widget.id)}`, { + cache: "no-store" + }) + ]); + + const configData = await parseJsonResponse(configResponse); + const eventsData = (await eventsResponse.json().catch(() => null)) as { + events?: CalendarEvent[]; + error?: string | null; + } | null; + + return { + widgetId: widget.id, + selectedSourceIds: configData.selectedSourceIds, + nextEventsCount: configData.nextEventsCount, + events: Array.isArray(eventsData?.events) ? eventsData.events : [], + error: eventsData?.error ?? null + }; + }) + ); + + const nextEventsByWidget: Record = {}; + const nextErrorsByWidget: Record = {}; + const nextSelectionsByWidget: Record = {}; + const nextEventsCountByWidget: Record = {}; + + calendarLoads.forEach((calendarLoad) => { + nextEventsByWidget[calendarLoad.widgetId] = calendarLoad.events; + nextErrorsByWidget[calendarLoad.widgetId] = calendarLoad.error; + nextSelectionsByWidget[calendarLoad.widgetId] = calendarLoad.selectedSourceIds; + nextEventsCountByWidget[calendarLoad.widgetId] = calendarLoad.nextEventsCount; + }); + + setSettings(settingsData.settings); + setWidgets(widgetsData.widgets); + setNotes(notesData.notes); + setCalendarEventsByWidget(nextEventsByWidget); + setCalendarErrorsByWidget(nextErrorsByWidget); + setCalendarSources(allSourcesData.sources); + setCalendarSelectionsByWidget(nextSelectionsByWidget); + setCalendarNextEventsCountByWidget(nextEventsCountByWidget); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Dashboard konnte nicht geladen werden."); + } + } + + async function patchWidget( + widgetId: string, + patch: Partial> + ) { + const previousWidgets = widgets; + + setWidgets((current) => + current.map((widget) => + widget.id === widgetId + ? { + ...widget, + ...patch + } + : widget + ) + ); + + try { + const response = await fetch(`/api/widgets/${encodeURIComponent(widgetId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(patch) + }); + + const data = await parseJsonResponse<{ widget: Widget }>(response); + + setWidgets((current) => current.map((widget) => (widget.id === widgetId ? data.widget : widget))); + } catch (error) { + setWidgets(previousWidgets); + setDashboardError(error instanceof Error ? error.message : "Widget konnte nicht gespeichert werden."); + } + } + + async function patchNote(noteId: string, patch: Partial>) { + const previousNotes = notes; + + setNotes((current) => + current.map((note) => + note.id === noteId + ? { + ...note, + ...patch + } + : note + ) + ); + + try { + const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(patch) + }); + + const data = await parseJsonResponse<{ note: NoteBoardItem }>(response); + + setNotes((current) => current.map((note) => (note.id === noteId ? data.note : note))); + } catch (error) { + setNotes(previousNotes); + setNoteError(error instanceof Error ? error.message : "Notiz konnte nicht gespeichert werden."); + } + } + + async function deleteNote(noteId: string) { + const confirmed = window.confirm("Diesen Eintrag wirklich löschen?"); + + if (!confirmed) { + return; + } + + const previousNotes = notes; + + setNotes((current) => current.filter((note) => note.id !== noteId)); + + try { + const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, { + method: "DELETE" + }); + + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { error?: string } | null; + + throw new Error(data?.error ?? "Notiz konnte nicht gelöscht werden."); + } + } catch (error) { + setNotes(previousNotes); + setNoteError(error instanceof Error ? error.message : "Notiz konnte nicht gelöscht werden."); + } + } + + async function persistLayout(layout: DashboardLayoutItem[]) { + const sortedLayout = sortLayoutForPosition(layout); + + try { + await Promise.all( + sortedLayout.map((item, index) => + fetch(`/api/widgets/${encodeURIComponent(item.i)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + position: index, + x: item.x, + y: item.y, + w: item.w, + h: item.h + }) + }).then((response) => { + if (!response.ok) { + throw new Error("Widget-Layout konnte nicht gespeichert werden."); + } + }) + ) + ); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Widget-Layout konnte nicht gespeichert werden."); + void loadDashboardData(); + } + } + + function handleLayoutChange(nextLayout: DashboardLayoutItem[]) { + if (!editMode) { + return; + } + + latestLayoutRef.current = nextLayout; + + const sortedLayout = sortLayoutForPosition(nextLayout); + const layoutById = new Map(sortedLayout.map((item, index) => [item.i, { item, position: index }])); + + setWidgets((current) => { + let changed = false; + + const nextWidgets = current.map((widget) => { + const layoutItem = layoutById.get(widget.id); + + if (!layoutItem) { + return widget; + } + + const nextWidget = { + ...widget, + position: layoutItem.position, + x: layoutItem.item.x, + y: layoutItem.item.y, + w: layoutItem.item.w, + h: layoutItem.item.h + }; + + if ( + nextWidget.position !== widget.position || + nextWidget.x !== widget.x || + nextWidget.y !== widget.y || + nextWidget.w !== widget.w || + nextWidget.h !== widget.h + ) { + changed = true; + } + + return nextWidget; + }); + + return changed ? nextWidgets : current; + }); + + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + + saveLayoutTimeoutRef.current = setTimeout(() => { + void persistLayout(latestLayoutRef.current); + }, 500); + } + + async function addWidget(type: WidgetType) { + setDashboardError(null); + + try { + const response = await fetch("/api/widgets", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type, + tabId: activeTabId + }) + }); + + const data = await parseJsonResponse<{ widget: Widget }>(response); + + setWidgets((current) => [...current, data.widget]); + setAddWidgetMenuOpen(false); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Widget konnte nicht erstellt werden."); + } + } + + async function deleteWidget(widget: Widget) { + const confirmed = window.confirm(`Widget "${widget.title}" wirklich löschen?`); + + if (!confirmed) { + return; + } + + setDashboardError(null); + + try { + const response = await fetch(`/api/widgets/${encodeURIComponent(widget.id)}`, { + method: "DELETE" + }); + + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { error?: string } | null; + + throw new Error(data?.error ?? "Widget konnte nicht gelöscht werden."); + } + + setWidgets((current) => + current + .filter((currentWidget) => currentWidget.id !== widget.id) + .sort((a, b) => a.position - b.position) + .map((currentWidget, index) => ({ + ...currentWidget, + position: index + })) + ); + + setOpenWidgetMenuId(null); + } catch (error) { + setDashboardError(error instanceof Error ? error.message : "Widget konnte nicht gelöscht werden."); + } + } + + async function handleLogin(event: FormEvent) { + event.preventDefault(); + setLoginError(null); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email: loginEmail, + password: loginPassword + }) + }); + + const data = await parseJsonResponse<{ user: User }>(response); + + setUser(data.user); + setLoginPassword(""); + } catch (error) { + setLoginError(error instanceof Error ? error.message : "Login fehlgeschlagen."); + } + } + + async function handleLogout() { + await fetch("/api/auth/logout", { + method: "POST" + }); + + setUser(null); + setWidgets([]); + setSettings(null); + setNotes([]); + setCalendarEventsByWidget({}); + setCalendarErrorsByWidget({}); + setCalendarSources([]); + setCalendarSelectionsByWidget({}); + setCalendarNextEventsCountByWidget({}); + setDashboardError(null); + setProfileMenuOpen(false); + } + + function handleSearch(event: FormEvent) { + event.preventDefault(); + + const cleanQuery = query.trim(); + + if (!cleanQuery) { + return; + } + + const url = activeProvider.urlTemplate.replace("{query}", encodeURIComponent(cleanQuery)); + const newWindow = window.open(url, "_blank", "noopener,noreferrer"); + + if (newWindow) { + newWindow.opener = null; + } + } + + async function toggleDarkMode() { + if (!settings) { + return; + } + + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + darkMode: !settings.darkMode + }) + }); + + const data = await parseJsonResponse<{ settings: Settings }>(response); + + setSettings(data.settings); + setProfileMenuOpen(false); + } + + function toggleEditMode() { + setEditMode((current) => !current); + setEditingNoteWidgetId(null); + setOpenWidgetMenuId(null); + setAddWidgetMenuOpen(false); + setProfileMenuOpen(false); + } + + function showPreviousMonth() { + setCalendarMonth((current) => new Date(current.getFullYear(), current.getMonth() - 1, 1)); + } + + function showNextMonth() { + setCalendarMonth((current) => new Date(current.getFullYear(), current.getMonth() + 1, 1)); + } + + function showCurrentMonth() { + setCalendarMonth(new Date()); + } + + function renderNoteEditButton(widget: Widget) { + if (widget.type !== "note" && widget.type !== "noteboard") { + return null; + } + + const isActive = editingNoteWidgetId === widget.id || editMode; + + return ( + + ); + } + + function renderWidgetMenu(widget: Widget) { + if (!editMode) { + return null; + } + + const isOpen = openWidgetMenuId === widget.id; + + return ( +
+ + + {isOpen ? ( +
+ + + + + + + + + + + + + +
+ ) : null} +
+ ); + } + + function renderWidgetTitle(widget: Widget) { + const noteTitleIsEditing = + (widget.type === "note" || widget.type === "noteboard") && editingNoteWidgetId === widget.id; + + const titleIsEditing = editMode || noteTitleIsEditing; + + if (!titleIsEditing) { + return

{widget.title}

; + } + + return ( + { + const nextTitle = event.target.value; + + setWidgets((current) => + current.map((currentWidget) => + currentWidget.id === widget.id + ? { + ...currentWidget, + title: nextTitle + } + : currentWidget + ) + ); + }} + onBlur={(event) => { + void patchWidget(widget.id, { + title: event.target.value + }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + aria-label="Widget-Titel bearbeiten" + /> + ); + } + + async function createMissingNoteForWidget(widget: Widget) { + const type = "note"; + + try { + const response = await fetch("/api/notes", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + id: widget.id, + type, + title: widget.title || "Notiz", + content: "" + }) + }); + + const data = await parseJsonResponse<{ note: NoteBoardItem }>(response); + + setNotes((current) => { + if (current.some((note) => note.id === data.note.id)) { + return current; + } + + return [...current, data.note]; + }); + } catch (error) { + setNoteError(error instanceof Error ? error.message : "Eintrag konnte nicht initialisiert werden."); + } + } + + function getNoteForWidget(widget: Widget): NoteBoardItem | null { + return notes.find((note) => note.id === widget.id) ?? null; + } + + function getNoteTextarea(noteId: string): HTMLTextAreaElement | null { + if (typeof document === "undefined") { + return null; + } + + return document.getElementById(`note-textarea-${noteId}`) as HTMLTextAreaElement | null; + } + + function updateNoteContentFromToolbar( + note: NoteBoardItem, + nextContent: string, + nextSelectionStart: number, + nextSelectionEnd = nextSelectionStart + ) { + setNotes((current) => + current.map((currentNote) => + currentNote.id === note.id + ? { + ...currentNote, + content: nextContent + } + : currentNote + ) + ); + + void patchNote(note.id, { + content: nextContent + }); + + window.setTimeout(() => { + const textarea = getNoteTextarea(note.id); + + if (!textarea) { + return; + } + + textarea.focus(); + textarea.setSelectionRange(nextSelectionStart, nextSelectionEnd); + }, 0); + } + + function wrapNoteSelection(note: NoteBoardItem, before: string, after: string, placeholder: string) { + const textarea = getNoteTextarea(note.id); + const value = note.content ?? ""; + const start = textarea?.selectionStart ?? value.length; + const end = textarea?.selectionEnd ?? value.length; + const selectedText = value.slice(start, end); + + const beforeSelection = value.slice(Math.max(0, start - before.length), start); + const afterSelection = value.slice(end, end + after.length); + + if (selectedText && beforeSelection === before && afterSelection === after) { + const nextContent = `${value.slice(0, start - before.length)}${selectedText}${value.slice(end + after.length)}`; + const nextStart = start - before.length; + const nextEnd = nextStart + selectedText.length; + + updateNoteContentFromToolbar(note, nextContent, nextStart, nextEnd); + return; + } + + if (selectedText.startsWith(before) && selectedText.endsWith(after) && selectedText.length > before.length + after.length) { + const unwrappedText = selectedText.slice(before.length, selectedText.length - after.length); + const nextContent = `${value.slice(0, start)}${unwrappedText}${value.slice(end)}`; + + updateNoteContentFromToolbar(note, nextContent, start, start + unwrappedText.length); + return; + } + + const insertedText = selectedText || placeholder; + const replacement = `${before}${insertedText}${after}`; + const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`; + const selectionStart = start + before.length; + const selectionEnd = selectionStart + insertedText.length; + + updateNoteContentFromToolbar(note, nextContent, selectionStart, selectionEnd); + } + + function prefixNoteLines(note: NoteBoardItem, prefix: string, placeholder: string) { + const textarea = getNoteTextarea(note.id); + const value = note.content ?? ""; + const start = textarea?.selectionStart ?? value.length; + const end = textarea?.selectionEnd ?? value.length; + const lineStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1; + const lineEndCandidate = end === start ? end : end; + const nextLineBreak = value.indexOf("\n", lineEndCandidate); + const lineEnd = nextLineBreak === -1 || end === start ? lineEndCandidate : lineEndCandidate; + const selectedBlock = value.slice(lineStart, lineEnd) || placeholder; + const lines = selectedBlock.split("\n"); + + const isCheckboxMode = prefix === "- [ ] "; + const isOrderedMode = prefix === "1. "; + const isBulletMode = prefix === "- "; + + const checkboxPattern = /^(\s*)[-*]\s+\[[ xX]\]\s+/; + const orderedPattern = /^(\s*)\d+\.\s+/; + const bulletPattern = /^(\s*)[-*]\s+(?!\[[ xX]\]\s+)/; + + const alreadyFormatted = lines.every((line) => { + if (!line.trim()) { + return true; + } + + if (isCheckboxMode) { + return checkboxPattern.test(line); + } + + if (isOrderedMode) { + return orderedPattern.test(line); + } + + if (isBulletMode) { + return bulletPattern.test(line); + } + + return false; + }); + + const nextLines = lines.map((line, index) => { + if (!line.trim()) { + return line; + } + + if (alreadyFormatted) { + if (isCheckboxMode) { + return line.replace(checkboxPattern, "$1"); + } + + if (isOrderedMode) { + return line.replace(orderedPattern, "$1"); + } + + if (isBulletMode) { + return line.replace(bulletPattern, "$1"); + } + + return line; + } + + const cleanLine = line + .replace(checkboxPattern, "$1") + .replace(orderedPattern, "$1") + .replace(bulletPattern, "$1"); + + if (isOrderedMode) { + return cleanLine.replace(/^(\s*)/, `$1${index + 1}. `); + } + + return cleanLine.replace(/^(\s*)/, `$1${prefix}`); + }); + + const prefixedBlock = nextLines.join("\n"); + const nextContent = `${value.slice(0, lineStart)}${prefixedBlock}${value.slice(lineEnd)}`; + + updateNoteContentFromToolbar(note, nextContent, lineStart, lineStart + prefixedBlock.length); + } + + function insertNoteLink(note: NoteBoardItem) { + const textarea = getNoteTextarea(note.id); + const value = note.content ?? ""; + const start = textarea?.selectionStart ?? value.length; + const end = textarea?.selectionEnd ?? value.length; + const selectedText = value.slice(start, end) || "Linktext"; + const replacement = `[${selectedText}](https://)`; + const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`; + const urlStart = start + selectedText.length + 3; + + updateNoteContentFromToolbar(note, nextContent, urlStart, urlStart + 8); + } + + function normalizeMarkdownHref(href: string): string { + const cleanHref = href.trim(); + + if (cleanHref.startsWith("/") || cleanHref.startsWith("#")) { + return cleanHref; + } + + try { + const parsedUrl = new URL(cleanHref); + + if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" || parsedUrl.protocol === "mailto:") { + return parsedUrl.toString(); + } + } catch { + return "#"; + } + + return "#"; + } + + function renderInlineMarkdown(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + const pattern = /(\*\*[^*\n]+?\*\*|\*[^*\n]+?\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)]+?\))/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + const token = match[0]; + + if (token.startsWith("**") && token.endsWith("**")) { + nodes.push({token.slice(2, -2)}); + } else if (token.startsWith("*") && token.endsWith("*")) { + nodes.push({token.slice(1, -1)}); + } else if (token.startsWith("`") && token.endsWith("`")) { + nodes.push({token.slice(1, -1)}); + } else { + const linkMatch = token.match(/^\[([^\]]+?)\]\(([^)]+?)\)$/); + + if (linkMatch) { + nodes.push( + + {linkMatch[1]} + + ); + } else { + nodes.push(token); + } + } + + key += 1; + lastIndex = pattern.lastIndex; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes; + } + + function toggleMarkdownCheckbox(note: NoteBoardItem, lineIndex: number) { + const lines = (note.content ?? "").split("\n"); + const line = lines[lineIndex] ?? ""; + const match = line.match(/^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/); + + if (!match) { + return; + } + + const isChecked = match[2].toLowerCase() === "x"; + lines[lineIndex] = `${match[1]}${isChecked ? " " : "x"}${match[3]}`; + + const nextContent = lines.join("\n"); + + setNotes((current) => + current.map((currentNote) => + currentNote.id === note.id + ? { + ...currentNote, + content: nextContent + } + : currentNote + ) + ); + + void patchNote(note.id, { + content: nextContent + }); + } + + function renderNoteMarkdownPreview(note: NoteBoardItem) { + const content = note.content ?? ""; + + if (!content.trim()) { + return

Noch keine Notiz.

; + } + + const lines = content.split("\n"); + const blocks: ReactNode[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + blocks.push(
); + index += 1; + continue; + } + + const headingMatch = line.match(/^(#{1,3})\s+(.+)$/); + + if (headingMatch) { + const level = headingMatch[1].length; + const className = `noteMarkdownHeading noteMarkdownHeading${level}`; + + blocks.push( +
+ {renderInlineMarkdown(headingMatch[2])} +
+ ); + + index += 1; + continue; + } + + const taskMatch = line.match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/); + + if (taskMatch) { + const tasks: Array<{ lineIndex: number; checked: boolean; text: string }> = []; + + while (index < lines.length) { + const currentMatch = lines[index].match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/); + + if (!currentMatch) { + break; + } + + tasks.push({ + lineIndex: index, + checked: currentMatch[2].toLowerCase() === "x", + text: currentMatch[3] + }); + + index += 1; + } + + blocks.push( +
+ {tasks.map((task) => ( + + ))} +
+ ); + + continue; + } + + const unorderedMatch = line.match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/); + + if (unorderedMatch) { + const items: Array<{ lineIndex: number; text: string }> = []; + + while (index < lines.length) { + const currentMatch = lines[index].match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/); + + if (!currentMatch) { + break; + } + + items.push({ + lineIndex: index, + text: currentMatch[1] + }); + + index += 1; + } + + blocks.push( +
    + {items.map((item) => ( +
  • {renderInlineMarkdown(item.text)}
  • + ))} +
+ ); + + continue; + } + + const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/); + + if (orderedMatch) { + const items: Array<{ lineIndex: number; text: string }> = []; + + while (index < lines.length) { + const currentMatch = lines[index].match(/^\s*\d+\.\s+(.+)$/); + + if (!currentMatch) { + break; + } + + items.push({ + lineIndex: index, + text: currentMatch[1] + }); + + index += 1; + } + + blocks.push( +
    + {items.map((item) => ( +
  1. {renderInlineMarkdown(item.text)}
  2. + ))} +
+ ); + + continue; + } + + blocks.push( +

+ {renderInlineMarkdown(line)} +

+ ); + + index += 1; + } + + return
{blocks}
; + } + + function renderNoteMarkdownToolbar(note: NoteBoardItem) { + return ( +
+ + + + + + + + + + + + + +
+ ); + } + + function renderNoteWidget(widget: Widget) { + const note = getNoteForWidget(widget); + + if (!note) { + return ( +
+ {noteError ?

{noteError}

: null} + + +
+ ); + } + + const noteIsEditing = editMode || editingNoteWidgetId === widget.id; + + if (!noteIsEditing) { + return renderNoteMarkdownPreview(note); + } + + return ( +
+ {noteError ?

{noteError}

: null} + + {renderNoteMarkdownToolbar(note)} + +