feat: add mobile-responsive layout and card views across all pages
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
- New useIsMobile hook and MobileCardList component - AppLayout: hamburger menu + Drawer navigation on mobile - All list pages (Inbox, Posteingang, Manuell, Mail, Freigabe, Zahlung, TaskLog) show card layout on mobile instead of tables - CSS: responsive modal height, horizontal table scroll, text-size-adjust - Backend: Agrarmonitor polling fixes, Zahlung service improvements, IMAP folder service extended, misc controller fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge } from 'antd';
|
||||
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge, Drawer, Button } from 'antd';
|
||||
import {
|
||||
InboxOutlined,
|
||||
FileTextOutlined,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
GlobalOutlined,
|
||||
CheckCircleOutlined,
|
||||
EuroOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useTheme } from '../theme/ThemeContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Permission } from '../auth/permissions';
|
||||
import { statsApi, type StatsCounts } from '../api/stats';
|
||||
|
||||
@@ -47,17 +49,22 @@ const allMenuItems: MenuItemDef[] = [
|
||||
|
||||
export default function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout, hasPermission, isAuthenticated } = useAuth();
|
||||
const { token: themeToken } = theme.useToken();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [counts, setCounts] = useState<StatsCounts | null>(null);
|
||||
|
||||
// Im Drawer (mobil) ist die Navigation nie eingeklappt
|
||||
const effectiveCollapsed = isMobile ? false : collapsed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
|
||||
const fetchCounts = async () => {
|
||||
try {
|
||||
const data = await statsApi.getCounts();
|
||||
@@ -66,10 +73,10 @@ export default function AppLayout() {
|
||||
console.error('Fehler beim Abrufen der Zählerstände:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 30000); // 30 Sekunden Polling
|
||||
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, location.pathname]); // Update after navigation or auth change
|
||||
|
||||
@@ -80,9 +87,9 @@ export default function AppLayout() {
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
|
||||
<span>{item.label}</span>
|
||||
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && (
|
||||
<Badge
|
||||
count={counts[item.countKey]}
|
||||
{item.countKey && counts && counts[item.countKey] > 0 && !effectiveCollapsed && (
|
||||
<Badge
|
||||
count={counts[item.countKey]}
|
||||
overflowCount={99}
|
||||
size="small"
|
||||
color={isDark ? themeToken.colorPrimary : '#1677ff'}
|
||||
@@ -107,158 +114,231 @@ export default function AppLayout() {
|
||||
|
||||
const logoColor = isDark ? '#fff' : '#1a1a2e';
|
||||
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
|
||||
const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea';
|
||||
|
||||
// Sidebar-Inhalt (Logo, Menü, Bottom-Sektion) — identisch für Sider (Desktop)
|
||||
// und Drawer (mobil). `onNavigate` schließt auf Mobil den Drawer.
|
||||
const renderSidebarContent = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||
<>
|
||||
{/* Logo / Collapse-Toggle */}
|
||||
<button
|
||||
onClick={() => (onNavigate ? onNavigate() : setCollapsed(!collapsed))}
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
border: 'none',
|
||||
borderBottom: `1px solid ${dividerColor}`,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Text strong style={{ color: logoColor, fontSize: isCollapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
|
||||
{isCollapsed ? 'PM' : 'Paperless'}
|
||||
</Text>
|
||||
</button>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<Menu
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
const item = allMenuItems.find((i) => i.key === key);
|
||||
if (item?.externalUrl) {
|
||||
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
navigate(key);
|
||||
}
|
||||
onNavigate?.();
|
||||
}}
|
||||
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
|
||||
/>
|
||||
|
||||
{/* Bottom Section: User + Theme Toggle */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: `1px solid ${dividerColor}`,
|
||||
padding: isCollapsed ? '12px 0' : '12px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
transition: 'padding 0.2s',
|
||||
}}
|
||||
>
|
||||
{/* Theme Toggle */}
|
||||
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: isCollapsed ? '8px 0' : '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
color: subtleColor,
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
|
||||
{!isCollapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'user-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Benutzereinstellungen',
|
||||
onClick: () => {
|
||||
navigate('/user-settings');
|
||||
onNavigate?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Abmelden',
|
||||
onClick: () => logout(),
|
||||
},
|
||||
],
|
||||
}}
|
||||
placement="topRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: isCollapsed ? '8px 0' : '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{!isCollapsed && (
|
||||
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
|
||||
{user?.profile?.name || 'Benutzer'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
width={240}
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...siderStyle,
|
||||
}}
|
||||
>
|
||||
{/* Logo / Collapse-Toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
border: 'none',
|
||||
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Text strong style={{ color: logoColor, fontSize: collapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
|
||||
{collapsed ? 'PM' : 'Paperless'}
|
||||
</Text>
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Mobile Top-Bar mit Hamburger */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 48,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 8px',
|
||||
background: themeToken.colorBgContainer,
|
||||
borderBottom: `1px solid ${dividerColor}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
aria-label="Menü öffnen"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
/>
|
||||
<Text strong style={{ color: logoColor, fontSize: 18 }}>Paperless</Text>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<Menu
|
||||
<Drawer
|
||||
placement="left"
|
||||
width={260}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
closable={false}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
background: isDark ? '#001529' : '#f0f2f7',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderSidebarContent(false, () => setDrawerOpen(false))}
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
<Sider
|
||||
width={240}
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
const item = allMenuItems.find((i) => i.key === key);
|
||||
if (item?.externalUrl) {
|
||||
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
navigate(key);
|
||||
}
|
||||
}}
|
||||
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
|
||||
/>
|
||||
|
||||
{/* Bottom Section: User + Theme Toggle */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
|
||||
padding: collapsed ? '12px 0' : '12px 16px',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
transition: 'padding 0.2s',
|
||||
...siderStyle,
|
||||
}}
|
||||
>
|
||||
{/* Theme Toggle */}
|
||||
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: collapsed ? '8px 0' : '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
color: subtleColor,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
|
||||
{!collapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{renderSidebarContent(collapsed)}
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'user-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Benutzereinstellungen',
|
||||
onClick: () => navigate('/user-settings'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Abmelden',
|
||||
onClick: () => logout(),
|
||||
},
|
||||
],
|
||||
}}
|
||||
placement="topRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: collapsed ? '8px 0' : '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{!collapsed && (
|
||||
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
|
||||
{user?.profile?.name || 'Benutzer'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
||||
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}>
|
||||
<Layout
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
|
||||
marginTop: isMobile ? 48 : 0,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
style={{
|
||||
margin: isMobile ? 8 : 24,
|
||||
padding: isMobile ? 12 : 24,
|
||||
background: themeToken.colorBgContainer,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user