From 5e6f7e872d408ac8cf8ca77641d29a515316b261 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:53:29 +0100 Subject: [PATCH] updated --- app/(app)/dashboard/page.tsx | 177 ++++++++++- app/(app)/devices/DeviceDetailModal.tsx | 151 +++++----- app/(app)/devices/DeviceEditModal.tsx | 1 + app/(app)/devices/page.tsx | 208 +++++++++---- app/(app)/layout.tsx | 12 +- app/(app)/users/EditUserModal.tsx | 28 +- app/(app)/users/UsersTablesClient.tsx | 6 + app/api/profile/avatar/route.ts | 120 ++++++++ app/layout.tsx | 34 ++- components/DeviceQrCode.tsx | 2 +- components/ProfileAvatarModal.tsx | 184 ++++++++++++ components/UserMenu.tsx | 168 +++++++---- components/ui/Alerts.tsx | 239 +++++++++++++++ components/ui/Combobox.tsx | 2 +- components/ui/LoadingSpinner.tsx | 39 +++ components/ui/Table.tsx | 186 +++++++----- components/ui/Tabs.tsx | 284 ++++++++++++++++-- components/ui/UserAvatar.tsx | 21 +- generated/prisma/client.ts | 2 +- generated/prisma/internal/class.ts | 20 +- generated/prisma/internal/prismaNamespace.ts | 29 +- .../prisma/internal/prismaNamespaceBrowser.ts | 3 +- generated/prisma/models/User.ts | 53 +++- lib/auth-options.ts | 32 +- package-lock.json | 132 ++++---- package.json | 4 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 28 files changed, 1719 insertions(+), 421 deletions(-) create mode 100644 app/api/profile/avatar/route.ts create mode 100644 components/ProfileAvatarModal.tsx create mode 100644 components/ui/Alerts.tsx create mode 100644 components/ui/LoadingSpinner.tsx create mode 100644 prisma/migrations/20251205103042_add_avatar_url_to_user/migration.sql diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 8ca9e52..20e3645 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,14 +1,177 @@ // app/(app)/dashboard/page.tsx +import Alerts from '@/components/ui/Alerts'; +import { prisma } from '@/lib/prisma'; + +const dtf = new Intl.DateTimeFormat('de-DE', { + dateStyle: 'short', +}); + +export default async function DashboardPage() { + const now = new Date(); + + // Start / Ende des heutigen Tages (ohne Uhrzeit) + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const startOfTomorrow = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + 1, + ); + + // 🔴 Überfällige Geräte: loanedUntil < heute + const overdueDevices = await prisma.device.findMany({ + where: { + loanedTo: { not: null }, + loanedUntil: { lt: startOfToday }, + }, + orderBy: { loanedUntil: 'asc' }, + select: { + inventoryNumber: true, + name: true, + loanedTo: true, + loanedUntil: true, + }, + }); + + // 🟡 Heute fällige Geräte: loanedUntil am heutigen Tag + const dueTodayDevices = await prisma.device.findMany({ + where: { + loanedTo: { not: null }, + loanedUntil: { + gte: startOfToday, + lt: startOfTomorrow, + }, + }, + orderBy: { loanedUntil: 'asc' }, + select: { + inventoryNumber: true, + name: true, + loanedTo: true, + loanedUntil: true, + }, + }); + + const hasOverdue = overdueDevices.length > 0; + const hasDueToday = dueTodayDevices.length > 0; -export default function DashboardPage() { return ( <> -

- Geräte-Inventar -

-

- Hier könntest du gleich als Nächstes eine Übersicht deiner Geräte einbauen. -

+ {/* 🔴 Überfällige Geräte (rot) */} + {hasOverdue && ( +
+ +

Diese Geräte haben das Rückgabedatum bereits überschritten:

+ +
+ } + rightContent={ + + Zur Geräteliste + + + } + /> + + )} + + {/* 🟡 Heute fällige Geräte (gelb) */} + {hasDueToday && ( +
+ +

Diese Geräte sollten heute zurückgegeben werden:

+ +
+ } + rightContent={ + + Zur Geräteliste + + + } + /> + + )} ); } diff --git a/app/(app)/devices/DeviceDetailModal.tsx b/app/(app)/devices/DeviceDetailModal.tsx index 6b55668..e1586a7 100644 --- a/app/(app)/devices/DeviceDetailModal.tsx +++ b/app/(app)/devices/DeviceDetailModal.tsx @@ -44,8 +44,6 @@ type DeviceDetailsGridProps = { function DeviceDetailsGrid({ device, onStartLoan, - canEdit, - onEdit, }: DeviceDetailsGridProps) { const [activeSection, setActiveSection] = @@ -114,6 +112,7 @@ function DeviceDetailsGrid({ { id: 'info', label: 'Stammdaten' }, { id: 'zubehoer', label: 'Zubehör' }, ]} + variant='pillsBrand' value={activeSection} onChange={(id) => setActiveSection(id as 'info' | 'zubehoer') @@ -142,69 +141,59 @@ function DeviceDetailsGrid({ Status

-
- {/* linke „Spalte“: nur inhaltsbreit */} -
- +
+ {/* Zeile 1: Badge + Buttons nebeneinander */} +
+ {/* Badge */} +
- {statusLabel} - - - {device.loanedTo && ( - - an{' '} - - {device.loanedTo} - - {device.loanedFrom && ( - <> - {' '} - seit{' '} - {dtf.format(new Date(device.loanedFrom))} - - )} - {device.loanedUntil && ( - <> - {' '} - bis{' '} - {dtf.format(new Date(device.loanedUntil))} - - )} - {device.loanComment && ( - <> - {' '} - - Hinweis: {device.loanComment} - - )} + className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`} + > + + {statusLabel} - )} -
+
- {/* rechte Seite: Buttons */} -
- - - {canEdit && onEdit && ( + {/* rechte Seite: Buttons */} +
- )} +
+ + {/* Zeile 2: Verleih-Details über volle Breite */} + {device.loanedTo && ( +

+ an{' '} + + {device.loanedTo} + + {device.loanedFrom && ( + <> + {' '}seit{' '} + {dtf.format(new Date(device.loanedFrom))} + + )} + {device.loanedUntil && ( + <> + {' '}bis{' '} + {dtf.format(new Date(device.loanedUntil))} + + )} + {device.loanComment && ( + <> + {' '}– Hinweis: {device.loanComment} + + )} +

+ )}
@@ -531,16 +520,34 @@ export default function DeviceDetailModal({ }} headerExtras={ device && ( -
- setActiveTab(id as 'details' | 'history')} - ariaLabel="Ansicht wählen" - /> +
+ {/* Mobile: Tabs im Header */} +
+ + setActiveTab(id as 'details' | 'history') + } + ariaLabel="Ansicht wählen" + /> +
+ + {/* Rechts: Bearbeiten-Button nur wenn erlaubt */} + {canEdit && onEdit && ( + + )}
) } @@ -550,13 +557,13 @@ export default function DeviceDetailModal({ {/* QR-Code oben, nicht scrollend */}
-
+
+

+ {device.inventoryNumber} +

-

- {device.inventoryNumber} -

@@ -606,7 +613,7 @@ export default function DeviceDetailModal({ )}
- {/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */} + {/* Desktop */}
setActiveTab(id as 'fields' | 'relations')} ariaLabel="Bearbeitungsansicht wählen" diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx index 94be2de..7ef7498 100644 --- a/app/(app)/devices/page.tsx +++ b/app/(app)/devices/page.tsx @@ -1,8 +1,8 @@ -// app/(app)/devices/page.tsx 'use client'; import { useCallback, useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; import Button from '@/components/ui/Button'; import Table, { TableColumn } from '@/components/ui/Table'; import { Dropdown } from '@/components/ui/Dropdown'; @@ -18,6 +18,7 @@ import type { TagOption } from '@/components/ui/TagMultiCombobox'; import DeviceEditModal from './DeviceEditModal'; import DeviceDetailModal from './DeviceDetailModal'; import DeviceCreateModal from './DeviceCreateModal'; +import Badge from '@/components/ui/Badge'; export type AccessorySummary = { inventoryNumber: string; @@ -54,8 +55,11 @@ export type DeviceDetail = { updatedAt: string | null; }; +type PrimaryTab = 'main' | 'accessories' | 'all'; +type StatusTab = 'all' | 'loaned' | 'dueToday' | 'overdue'; + function formatDate(iso: string | null | undefined) { - if (!iso) return '–'; // oder '' wenn du es leer willst + if (!iso) return '–'; return new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', @@ -125,8 +129,28 @@ const columns: TableColumn[] = [ header: 'Tags', sortable: false, canHide: true, - render: (row) => - row.tags && row.tags.length > 0 ? row.tags.join(', ') : '', + cellClassName: 'whitespace-normal max-w-xs', + render: (row) => { + const tags = row.tags ?? []; + + if (!tags.length) return null; + + return ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ); + }, }, { key: 'updatedAt', @@ -138,6 +162,8 @@ const columns: TableColumn[] = [ ]; export default function DevicesPage() { + const { data: session } = useSession(); + const [devices, setDevices] = useState([]); const [listLoading, setListLoading] = useState(false); const [listError, setListError] = useState(null); @@ -151,21 +177,15 @@ export default function DevicesPage() { const searchParams = useSearchParams(); const router = useRouter(); - // TODO: Ersetze das durch deinen echten User-/Gruppen-Mechanismus - // Beispiel: aktuelle Benutzergruppen (z.B. aus Context oder eigenem Hook) - const currentUserGroups: string[] = []; // Platzhalter - // Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen - const canEditDevices = currentUserGroups.includes('INVENTAR_ADMIN'); + const canEditDevices = Boolean( + (session?.user as any)?.groupCanEditDevices, + ); - // 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle - const [activeTab, setActiveTab] = - useState<'main' | 'accessories' | 'all'>('main'); - - // 🔹 Counters für Badges - const mainCount = devices.filter((d) => !d.parentInventoryNumber).length; - const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length; - const allCount = devices.length; + // 🔹 Oberste Tabs: Hauptgeräte / Zubehör / Alle Geräte + const [primaryTab, setPrimaryTab] = useState('all'); + // 🔹 Untere Tabs: Leihstatus + const [statusTab, setStatusTab] = useState('all'); /* ───────── Geräte-Liste laden ───────── */ @@ -210,7 +230,7 @@ export default function DevicesPage() { }, [loadDevices]); useEffect(() => { - if (!searchParams) return; // TS happy + if (!searchParams) return; const fromDevice = searchParams.get('device'); const fromInventory = @@ -317,8 +337,6 @@ export default function DevicesPage() { return; } - // Optimistisch aus lokaler Liste entfernen - // (zusätzlich kommt noch der Socket-Event device:deleted) setDevices((prev) => prev.filter((d) => d.inventoryNumber !== inventoryNumber), ); @@ -346,15 +364,12 @@ export default function DevicesPage() { setDetailInventoryNumber(null); if (!searchParams) { - // Fallback: einfach auf /devices ohne Query router.replace('/devices', { scroll: false }); return; } - // ReadonlyURLSearchParams → string → URLSearchParams kopieren const params = new URLSearchParams(searchParams.toString()); - // alle möglichen Detail-Parameter entfernen params.delete('device'); params.delete('inventoryNumber'); params.delete('inv'); @@ -367,28 +382,78 @@ export default function DevicesPage() { const handleEditFromDetail = useCallback( (inventoryNumber: string) => { - // Detail-Modal schließen + URL /device-Query aufräumen closeDetailModal(); - // danach Edit-Modal öffnen setEditInventoryNumber(inventoryNumber); }, [closeDetailModal], ); + /* ───────── Counter & Filter ───────── */ - /* ───────── Filter nach Tab ───────── */ + // Tag-Grenzen für "heute" + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const tomorrowStart = new Date(todayStart); + tomorrowStart.setDate(tomorrowStart.getDate() + 1); - const filteredDevices = devices.filter((d) => { - if (activeTab === 'main') { - // Hauptgeräte: kein parent → eigenständig - return !d.parentInventoryNumber; + // Counts für oberste Tabs (immer über alle Geräte) + const mainCount = devices.filter((d) => !d.parentInventoryNumber).length; + const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length; + const allCount = devices.length; + + // Zuerst nach primaryTab filtern → Basis-Menge für Status-Tabs + const baseDevices = devices.filter((d) => { + const hasParent = !!d.parentInventoryNumber; + switch (primaryTab) { + case 'main': + return !hasParent; + case 'accessories': + return hasParent; + case 'all': + default: + return true; } - if (activeTab === 'accessories') { - // Zubehör: hat ein Hauptgerät - return !!d.parentInventoryNumber; + }); + + // Counts für Status-Tabs (abhängig vom gewählten primaryTab) + const loanedCount = baseDevices.filter((d) => !!d.loanedTo).length; + const overdueCount = baseDevices.filter((d) => { + if (!d.loanedTo || !d.loanedUntil) return false; + const until = new Date(d.loanedUntil); + return until < todayStart; + }).length; + const dueTodayCount = baseDevices.filter((d) => { + if (!d.loanedTo || !d.loanedUntil) return false; + const until = new Date(d.loanedUntil); + return until >= todayStart && until < tomorrowStart; + }).length; + + // Endgültige Filterung nach StatusTab + const filteredDevices = baseDevices.filter((d) => { + const isLoaned = !!d.loanedTo; + const until = d.loanedUntil ? new Date(d.loanedUntil) : null; + + switch (statusTab) { + case 'all': + return true; + case 'loaned': + return isLoaned; + case 'overdue': + return ( + isLoaned && + !!until && + until < todayStart + ); + case 'dueToday': + return ( + isLoaned && + !!until && + until >= todayStart && + until < tomorrowStart + ); + default: + return true; } - // "all" - return true; }); /* ───────── Render ───────── */ @@ -421,10 +486,17 @@ export default function DevicesPage() { )}
- {/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */} -
+ {/* 🔹 Tabs: oben Gerätetyp, darunter Leihstatus */} +
+ {/* Oberste Ebene */} setPrimaryTab(id as PrimaryTab)} + ariaLabel="Geräte-Typ filtern" + /> + + {/* Untere Ebene: Leihstatus (abhängig von primaryTab, Counts basieren auf baseDevices) */} + - setActiveTab(id as 'main' | 'accessories' | 'all') - } - ariaLabel="Geräteliste filtern" + value={statusTab} + onChange={(id) => setStatusTab(id as StatusTab)} + ariaLabel="Leihstatus filtern" />
- {listLoading && ( -

- Geräte werden geladen … -

- )} - {listError && (

{listError} @@ -469,6 +558,7 @@ export default function DevicesPage() { getRowId={(row) => row.inventoryNumber} selectable actionsHeader="" + isLoading={listLoading} renderActions={(row) => (

{/* Desktop: drei Icon-Buttons nebeneinander */} @@ -482,14 +572,16 @@ export default function DevicesPage() { onClick={() => handleDetails(row.inventoryNumber)} /> -
+ + {/* 🔹 Info: Darf Geräte bearbeiten (über Gruppe gesteuert) */} +
+
+ + Darf Geräte bearbeiten + + + Dieser Status wird durch die zugewiesene Gruppe gesteuert. + +
+ + { /* read-only, wird durch Gruppe bestimmt */ }} + disabled + ariaLabel="Benutzer darf Geräte bearbeiten (über Gruppe gesteuert)" + /> +
); diff --git a/app/(app)/users/UsersTablesClient.tsx b/app/(app)/users/UsersTablesClient.tsx index dd9404f..de63697 100644 --- a/app/(app)/users/UsersTablesClient.tsx +++ b/app/(app)/users/UsersTablesClient.tsx @@ -684,6 +684,7 @@ export default function UsersTablesClient({
setEditUser(null)} onSubmit={handleSaveEdit} + canEditDevices={(() => { + const group = allGroups.find((g) => g.id === editUser.groupId); + return !!group?.canEditDevices; + })()} /> )} diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..2501c37 --- /dev/null +++ b/app/api/profile/avatar/route.ts @@ -0,0 +1,120 @@ +// app/api/profile/avatar/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth-options'; +import { prisma } from '@/lib/prisma'; +import fs from 'fs'; +import path from 'path'; + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + + const user = session?.user as any | undefined; + const nwkennung: string | undefined = user?.nwkennung; + + if (!session || !nwkennung) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get('avatar'); + + if (!file || !(file instanceof Blob)) { + return NextResponse.json({ error: 'NO_FILE' }, { status: 400 }); + } + + const MAX_SIZE = 5 * 1024 * 1024; // 5 MB + const mime = file.type || ''; + + if (!mime.startsWith('image/')) { + return NextResponse.json({ error: 'INVALID_TYPE' }, { status: 400 }); + } + + const size: number | undefined = file.size; + if (typeof size === 'number' && size > MAX_SIZE) { + return NextResponse.json( + { error: 'TOO_LARGE', maxSizeBytes: MAX_SIZE }, + { status: 413 }, + ); + } + + // Dateiendung bestimmen + let ext = ''; + if ('name' in file) { + const name = (file as any).name as string; + ext = path.extname(name); + } + if (!ext) { + if (mime === 'image/jpeg') ext = '.jpg'; + else if (mime === 'image/png') ext = '.png'; + else if (mime === 'image/gif') ext = '.gif'; + else ext = '.img'; + } + + const avatarsDir = path.join(process.cwd(), 'public', 'avatars'); + await fs.promises.mkdir(avatarsDir, { recursive: true }); + + // alte Avatare löschen + const existingFiles = await fs.promises.readdir(avatarsDir); + await Promise.all( + existingFiles + .filter((f) => f.startsWith(`${nwkennung}-`)) + .map((f) => fs.promises.unlink(path.join(avatarsDir, f))), + ); + + // Neuer, eindeutiger Dateiname + const timestamp = Date.now(); + const fileName = `${nwkennung}-${timestamp}${ext.toLowerCase()}`; + const filePath = path.join(avatarsDir, fileName); + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await fs.promises.writeFile(filePath, buffer); + + const avatarUrl = `/avatars/${fileName}`; + + await prisma.user.update({ + where: { nwkennung }, + data: { avatarUrl }, + }); + + return NextResponse.json({ ok: true, avatarUrl }); +} + +// 👇 Neu: Profilbild löschen +export async function DELETE(req: NextRequest) { + const session = await getServerSession(authOptions); + + const user = session?.user as any | undefined; + const nwkennung: string | undefined = user?.nwkennung; + + if (!session || !nwkennung) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const avatarsDir = path.join(process.cwd(), 'public', 'avatars'); + + // Dateien löschen (falls vorhanden) + try { + const existingFiles = await fs.promises.readdir(avatarsDir); + const userFiles = existingFiles.filter((f) => + f.startsWith(`${nwkennung}-`), + ); + await Promise.all( + userFiles.map((f) => fs.promises.unlink(path.join(avatarsDir, f))), + ); + } catch (err: any) { + // Wenn es den Ordner nicht gibt, ignorieren + if (err?.code !== 'ENOENT') { + console.error('[DELETE /api/profile/avatar] cleanup error', err); + } + } + + // avatarUrl in DB auf null setzen + await prisma.user.update({ + where: { nwkennung }, + data: { avatarUrl: null }, + }); + + return NextResponse.json({ ok: true, avatarUrl: null }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 94d9981..adaeeb8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,23 +1,30 @@ // /app/layout.tsx - -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; -import Providers from "./providers"; +import type { Metadata, Viewport } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import Providers from './providers'; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: '--font-geist-sans', + subsets: ['latin'], }); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: '--font-geist-mono', + subsets: ['latin'], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Create Next App', + description: 'Generated by create next app', +}; + +// 👇 Neu +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, // verhindert Pinch-Zoom }; export default function RootLayout({ @@ -26,7 +33,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/components/DeviceQrCode.tsx b/components/DeviceQrCode.tsx index b7a54c9..ea9a51a 100644 --- a/components/DeviceQrCode.tsx +++ b/components/DeviceQrCode.tsx @@ -41,7 +41,7 @@ export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps) value={qrValue} size={size} level="M" - includeMargin + marginSize={2} bgColor="#FFFFFF" fgColor="#000000" /> diff --git a/components/ProfileAvatarModal.tsx b/components/ProfileAvatarModal.tsx new file mode 100644 index 0000000..36fbfb4 --- /dev/null +++ b/components/ProfileAvatarModal.tsx @@ -0,0 +1,184 @@ +// components/ProfileAvatarModal.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import Modal from '@/components/ui/Modal'; +import PersonAvatar from '@/components/ui/UserAvatar'; +import Button from './ui/Button'; + +type ProfileAvatarModalProps = { + open: boolean; + onClose: () => void; + avatarName: string; + avatarUrl?: string | null; + /** + * Wird aufgerufen, wenn eine neue Datei gespeichert werden soll. + */ + onAvatarSelected?: (file: File) => Promise | void; + /** + * Optional: wird aufgerufen, wenn der Nutzer das Profilbild löschen möchte. + * Erwartet, dass Backend + Session angepasst werden (Avatar auf null). + */ + onAvatarDelete?: () => Promise | void; +}; + +export default function ProfileAvatarModal({ + open, + onClose, + avatarName, + avatarUrl, + onAvatarSelected, + onAvatarDelete, +}: ProfileAvatarModalProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Wenn Modal geschlossen wird → State zurücksetzen + useEffect(() => { + if (!open) { + setSelectedFile(null); + setPreviewUrl(null); + setIsSaving(false); + } + }, [open]); + + // Preview-URL für ausgewählte Datei erzeugen + useEffect(() => { + if (!selectedFile) { + setPreviewUrl(null); + return; + } + + const url = URL.createObjectURL(selectedFile); + setPreviewUrl(url); + + return () => URL.revokeObjectURL(url); + }, [selectedFile]); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + const handleSave = async () => { + if (!selectedFile) return; + + try { + setIsSaving(true); + if (onAvatarSelected) { + await onAvatarSelected(selectedFile); + } + onClose(); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteClick = async () => { + if (!onAvatarDelete) return; + + const sure = window.confirm('Profilbild wirklich entfernen?'); + if (!sure) return; + + try { + setIsSaving(true); + await onAvatarDelete(); + // lokale Preview zurücksetzen + setSelectedFile(null); + setPreviewUrl(null); + onClose(); + } finally { + setIsSaving(false); + } + }; + + const effectiveAvatarUrl = previewUrl ?? avatarUrl ?? undefined; + const hasDeletableAvatar = !!avatarUrl; // Button nur anzeigen, wenn ein Avatar existiert + + return ( + +
+

+ Wähle ein neues Profilbild aus oder entferne das aktuelle Bild.
+ Unterstützte Formate: JPG, PNG, GIF. +

+ + {/* Aktuell vs. Vorschau */} +
+
+ + Aktuell + + +
+ +
+ + Vorschau + + +
+
+ + {/* File-Input + ggf. Lösch-Button */} +
+ + + {/* Profilbild löschen nur, wenn wirklich eins vorhanden ist */} + {onAvatarDelete && hasDeletableAvatar && ( + + )} +
+
+
+ ); +} diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index 7ca8e35..da9d224 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from 'react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; -import { signOut } from 'next-auth/react'; +import { signOut, useSession } from 'next-auth/react'; import PersonAvatar from '@/components/ui/UserAvatar'; +import ProfileAvatarModal from '@/components/ProfileAvatarModal'; export type UserMenuProps = { displayName: string; @@ -13,7 +14,7 @@ export type UserMenuProps = { }; const userNavigation = [ - { name: 'Your profile', href: '#' }, + { name: 'Profilbild ändern', href: '#' }, { name: 'Abmelden', href: '#' }, ]; @@ -23,6 +24,11 @@ export default function UserMenu({ avatarUrl, }: UserMenuProps) { const [open, setOpen] = useState(false); + const [avatarModalOpen, setAvatarModalOpen] = useState(false); + + const { update } = useSession(); + + const [currentAvatarUrl, setCurrentAvatarUrl] = useState(avatarUrl ?? null); const buttonRef = useRef(null); const menuRef = useRef(null); @@ -66,66 +72,122 @@ export default function UserMenu({ const handleItemClick = (itemName: string) => { setOpen(false); + if (itemName === 'Profilbild ändern') { + setAvatarModalOpen(true); + return; + } + if (itemName === 'Abmelden') { void signOut({ callbackUrl: '/login' }); return; } + }; - // hier könntest du später noch Routing für "Your profile" o.ä. einbauen + const handleAvatarSelected = async (file: File) => { + const formData = new FormData(); + formData.append('avatar', file); + + const res = await fetch('/api/profile/avatar', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + console.error('Avatar-Upload fehlgeschlagen'); + return; + } + + + const data = await res.json(); + const newUrl = data.avatarUrl as string | null; + + // 1) Lokal sofort aktualisieren + setCurrentAvatarUrl(newUrl); + + // 2) NextAuth-Session aktualisieren → layout.tsx bekommt neue avatarUrl + await update({ avatarUrl: newUrl }); + }; + + const handleAvatarDelete = async () => { + const res = await fetch('/api/profile/avatar', { + method: 'DELETE', + }); + + if (!res.ok) { + console.error('Avatar-Löschung fehlgeschlagen'); + return; + } + + // 1) Lokal zurück auf null + setCurrentAvatarUrl(null); + + // 2) Session updaten → überall Fallback-Initialen + await update({ avatarUrl: null }); }; return ( -
- - - {open && ( - + {displayName} + +
+ + {/* Profilbild-Modal */} + setAvatarModalOpen(false)} + avatarName={avatarName} + avatarUrl={currentAvatarUrl ?? undefined} + onAvatarSelected={handleAvatarSelected} + onAvatarDelete={handleAvatarDelete} + /> + ); } diff --git a/components/ui/Alerts.tsx b/components/ui/Alerts.tsx new file mode 100644 index 0000000..2ba7594 --- /dev/null +++ b/components/ui/Alerts.tsx @@ -0,0 +1,239 @@ +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; +import { + InformationCircleIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + XCircleIcon, + XMarkIcon, +} from '@heroicons/react/20/solid'; + +export type AlertTone = 'info' | 'success' | 'warning' | 'error'; + +export interface AlertProps { + /** Farbschema / Typ des Alerts */ + tone?: AlertTone; + + /** Überschrift (z.B. "Attention needed") */ + title?: React.ReactNode; + + /** Beschreibungstext (als string oder JSX) */ + description?: React.ReactNode; + + /** Bullet-List-Einträge wie im "With list"-Beispiel */ + listItems?: React.ReactNode[]; + + /** Eigene Icon-Komponente (null = Icon komplett ausblenden) */ + icon?: React.ReactNode | null; + + /** Bereich für Buttons / Actions unter dem Text */ + actions?: React.ReactNode; + + /** Inhalt rechts (z.B. ein "Details →"-Link) */ + rightContent?: React.ReactNode; + + /** Linker Accent-Border (statt Outlines im Dark Mode) */ + accent?: boolean; + + /** Wenn gesetzt, wird ein Dismiss-X angezeigt und dieser Handler aufgerufen */ + onDismiss?: () => void; + + className?: string; +} + +const toneConfig: Record< + AlertTone, + { + bg: string; + outline: string; + accentBorder: string; + title: string; + text: string; + icon: string; + dismissBtn: string; + } +> = { + info: { + bg: 'bg-blue-50 dark:bg-blue-500/10', + outline: 'dark:outline dark:outline-blue-500/20', + accentBorder: 'border-l-4 border-blue-400 dark:border-blue-500', + title: 'text-blue-800 dark:text-blue-200', + text: 'text-blue-700 dark:text-blue-300', + icon: 'text-blue-400 dark:text-blue-300', + dismissBtn: + 'inline-flex rounded-md bg-blue-50 p-1.5 text-blue-500 hover:bg-blue-100 ' + + 'focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-50 focus-visible:outline-none ' + + 'dark:bg-transparent dark:text-blue-400 dark:hover:bg-blue-500/10 ' + + 'dark:focus-visible:ring-blue-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-blue-900', + }, + success: { + bg: 'bg-green-50 dark:bg-green-500/10', + outline: 'dark:outline dark:outline-green-500/20', + accentBorder: 'border-l-4 border-green-400 dark:border-green-500', + title: 'text-green-800 dark:text-green-200', + text: 'text-green-700 dark:text-green-200/85', + icon: 'text-green-400 dark:text-green-300', + dismissBtn: + 'inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 ' + + 'focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-none ' + + 'dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 ' + + 'dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900', + }, + warning: { + bg: 'bg-yellow-50 dark:bg-yellow-500/10', + outline: 'dark:outline dark:outline-yellow-500/15', + accentBorder: 'border-l-4 border-yellow-400 dark:border-yellow-500', + title: 'text-yellow-800 dark:text-yellow-100', + text: 'text-yellow-700 dark:text-yellow-100/80', + icon: 'text-yellow-400 dark:text-yellow-300', + dismissBtn: + 'inline-flex rounded-md bg-yellow-50 p-1.5 text-yellow-500 hover:bg-yellow-100 ' + + 'focus-visible:ring-2 focus-visible:ring-yellow-600 focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-50 focus-visible:outline-none ' + + 'dark:bg-transparent dark:text-yellow-400 dark:hover:bg-yellow-500/10 ' + + 'dark:focus-visible:ring-yellow-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-yellow-900', + }, + error: { + bg: 'bg-red-50 dark:bg-red-500/15', + outline: 'dark:outline dark:outline-red-500/25', + accentBorder: 'border-l-4 border-red-400 dark:border-red-500', + title: 'text-red-800 dark:text-red-200', + text: 'text-red-700 dark:text-red-200/80', + icon: 'text-red-400 dark:text-red-400', + dismissBtn: + 'inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 ' + + 'focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2 focus-visible:ring-offset-red-50 focus-visible:outline-none ' + + 'dark:bg-transparent dark:text-red-400 dark:hover:bg-red-500/10 ' + + 'dark:focus-visible:ring-red-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-red-900', + }, +}; + +function getDefaultIcon(tone: AlertTone) { + switch (tone) { + case 'info': + return ( +