feat: Dark Mode mit Toggle, localStorage-Persistenz und System-Preference

- Sun/Moon Toggle im Top-Bar
- Alle Seiten und Komponenten mit dark: Varianten
- Tailwind darkMode: 'class' Strategie
- Respektiert prefers-color-scheme als Default
This commit is contained in:
root
2026-03-20 23:51:06 +00:00
parent 9e3b431e7c
commit 3bad762329
18 changed files with 245 additions and 193 deletions
+3 -3
View File
@@ -29,7 +29,7 @@ export default function ConfirmDialog({
<div className="flex flex-col items-center text-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full ${
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
variant === 'danger' ? 'bg-red-100 dark:bg-red-900/40' : 'bg-amber-100 dark:bg-amber-900/40'
}`}
>
<AlertTriangle
@@ -37,8 +37,8 @@ export default function ConfirmDialog({
size={24}
/>
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-500">{message}</p>
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{message}</p>
<div className="mt-6 flex w-full gap-3">
<button
onClick={onClose}
+4 -4
View File
@@ -15,12 +15,12 @@ export default function EmptyState({
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<Icon className="text-gray-400" size={32} />
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
<Icon className="text-gray-400 dark:text-gray-500" size={32} />
</div>
<h3 className="mt-4 text-base font-semibold text-gray-900">{title}</h3>
<h3 className="mt-4 text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
{description && (
<p className="mt-1 text-sm text-gray-500">{description}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
)}
{action && <div className="mt-6">{action}</div>}
</div>
+34 -21
View File
@@ -6,13 +6,16 @@ import {
LayoutDashboard,
LogOut,
Menu,
Moon,
Settings,
Sun,
User,
Users,
X,
} from 'lucide-react';
import { Menu as HeadlessMenu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { useAuth } from '../hooks/useAuth';
import { useDarkMode } from '../hooks/useDarkMode';
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
@@ -29,6 +32,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = useState(false);
const { isDark, toggle: toggleDark } = useDarkMode();
const isAdmin =
user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
@@ -41,22 +45,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'
}`;
const sidebarContent = (
<>
<div className="flex h-16 items-center gap-3 border-b border-gray-200 px-6">
<div className="flex h-16 items-center gap-3 border-b border-gray-200 px-6 dark:border-gray-700">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-sm font-bold text-white">
WP
</div>
<div>
<h1 className="text-sm font-semibold text-gray-900">
<h1 className="text-sm font-semibold text-gray-900 dark:text-white">
Workflow Portal
</h1>
{user?.tenant && (
<p className="text-xs text-gray-500">{user.tenant.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{user.tenant.name}</p>
)}
</div>
</div>
@@ -77,8 +81,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{isAdmin && (
<>
<div className="my-4 border-t border-gray-200" />
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
<div className="my-4 border-t border-gray-200 dark:border-gray-700" />
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Administration
</p>
{adminNavigation.map((item) => (
@@ -96,17 +100,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
)}
</nav>
<div className="border-t border-gray-200 p-4">
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-400">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{user?.firstName} {user?.lastName}
</p>
<p className="truncate text-xs text-gray-500">{user?.email}</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{user?.email}</p>
</div>
</div>
</div>
@@ -114,7 +118,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50">
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
@@ -125,13 +129,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{/* Mobile sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-white shadow-xl transition-transform duration-200 lg:hidden ${
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-white shadow-xl transition-transform duration-200 lg:hidden dark:bg-gray-900 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<button
onClick={() => setSidebarOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100"
className="absolute right-3 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<X size={20} />
</button>
@@ -139,25 +143,34 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</aside>
{/* Desktop sidebar */}
<aside className="hidden w-64 flex-col border-r border-gray-200 bg-white lg:flex">
<aside className="hidden w-64 flex-col border-r border-gray-200 bg-white lg:flex dark:border-gray-700 dark:bg-gray-900">
{sidebarContent}
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top bar */}
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 lg:px-8">
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 lg:px-8 dark:border-gray-700 dark:bg-gray-800">
<button
onClick={() => setSidebarOpen(true)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 lg:hidden"
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 lg:hidden dark:text-gray-400 dark:hover:bg-gray-700"
>
<Menu size={22} />
</button>
<div className="flex-1" />
{/* Dark mode toggle */}
<button
onClick={toggleDark}
className="mr-2 rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
title={isDark ? 'Helles Design' : 'Dunkles Design'}
>
{isDark ? <Sun size={20} /> : <Moon size={20} />}
</button>
<HeadlessMenu as="div" className="relative">
<MenuButton className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">
<MenuButton className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
<User size={18} />
<span className="hidden sm:inline">
{user?.firstName} {user?.lastName}
@@ -172,13 +185,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<MenuItems className="absolute right-0 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white py-1 shadow-lg focus:outline-none">
<MenuItems className="absolute right-0 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white py-1 shadow-lg focus:outline-none dark:border-gray-700 dark:bg-gray-800">
<MenuItem>
{({ focus }) => (
<Link
to="/"
className={`flex items-center gap-2 px-4 py-2 text-sm ${
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
focus ? 'bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white' : 'text-gray-700 dark:text-gray-200'
}`}
>
<User size={16} />
@@ -191,7 +204,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<button
onClick={handleLogout}
className={`flex w-full items-center gap-2 px-4 py-2 text-sm ${
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
focus ? 'bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white' : 'text-gray-700 dark:text-gray-200'
}`}
>
<LogOut size={16} />
+3 -3
View File
@@ -51,17 +51,17 @@ export default function Modal({
leaveTo="opacity-0 scale-95"
>
<DialogPanel
className={`w-full ${sizeClasses[size]} transform rounded-xl bg-white p-6 shadow-xl transition-all`}
className={`w-full ${sizeClasses[size]} transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-gray-800`}
>
<div className="flex items-center justify-between">
{title && (
<DialogTitle className="text-lg font-semibold text-gray-900">
<DialogTitle className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</DialogTitle>
)}
<button
onClick={onClose}
className="ml-auto rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
className="ml-auto rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X size={20} />
</button>
+4 -4
View File
@@ -27,13 +27,13 @@ export default function Pagination({
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-700"
>
<ChevronLeft size={18} />
</button>
{pages.map((p, idx) =>
p === '...' ? (
<span key={`dots-${idx}`} className="px-2 text-gray-400">
<span key={`dots-${idx}`} className="px-2 text-gray-400 dark:text-gray-500">
...
</span>
) : (
@@ -43,7 +43,7 @@ export default function Pagination({
className={`min-w-[36px] rounded-lg px-3 py-1.5 text-sm font-medium ${
p === page
? 'bg-primary-600 text-white'
: 'text-gray-600 hover:bg-gray-100'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
}`}
>
{p}
@@ -53,7 +53,7 @@ export default function Pagination({
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-700"
>
<ChevronRight size={18} />
</button>
+2 -2
View File
@@ -35,7 +35,7 @@ export default function SearchBar({
return (
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
size={18}
/>
<input
@@ -48,7 +48,7 @@ export default function SearchBar({
{internal && (
<button
onClick={() => handleChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<X size={16} />
</button>
+4 -4
View File
@@ -6,19 +6,19 @@ const statusConfig: Record<
> = {
DRAFT: {
label: 'Entwurf',
className: 'bg-gray-100 text-gray-700 ring-gray-300',
className: 'bg-gray-100 text-gray-700 ring-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600',
},
REVIEW: {
label: 'In Prüfung',
className: 'bg-yellow-50 text-yellow-700 ring-yellow-300',
className: 'bg-yellow-50 text-yellow-700 ring-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:ring-yellow-700',
},
PUBLISHED: {
label: 'Veröffentlicht',
className: 'bg-green-50 text-green-700 ring-green-300',
className: 'bg-green-50 text-green-700 ring-green-300 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-700',
},
ARCHIVED: {
label: 'Archiviert',
className: 'bg-red-50 text-red-700 ring-red-300',
className: 'bg-red-50 text-red-700 ring-red-300 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-700',
},
};