This commit is contained in:
seb
2026-08-06 00:19:21 +02:00
parent 80ca59ab11
commit 855710a198
16 changed files with 270 additions and 185 deletions

View File

@@ -1,21 +1,24 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import IconButton from '@mui/material/IconButton';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import { NAV_ITEMS, isNavItemActive } from '../nav';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 80;
export default function AppShell() {
const navigate = useNavigate();
const { pathname } = useLocation();
class AppSidebar extends PureComponent {
render() {
const { activeNav, navigate } = this.props;
return (
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'background.default' }}>
return (
<Box
component="nav"
sx={(theme) => ({
@@ -28,7 +31,7 @@ export default function AppShell() {
>
<List disablePadding sx={{ flexGrow: 1 }}>
{NAV_ITEMS.map(({ path, label, icon: Icon }) => {
const active = isNavItemActive(pathname, path);
const active = path === activeNav;
return (
<ListItemButton
@@ -73,17 +76,113 @@ export default function AppShell() {
<UserMenu />
</Box>
);
}
}
class AppSubNav extends PureComponent {
render() {
const { activeNav, activeSub, navigate } = this.props;
const subNav = activeNav ? SUB_NAV[activeNav] : null;
if (!subNav?.length) {
return (
<Box
sx={{
width: SUB_NAV_WIDTH,
flexShrink: 0,
}}
/>
);
}
return (
<Box
component="nav"
aria-label="Unternavigation"
sx={{
width: SUB_NAV_WIDTH,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
{subNav.map(({ segment, label, icon: Icon }) => {
const active = segment === activeSub;
return (
<IconButton
key={segment || 'index'}
type="button"
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && activeNav === '/artikel') {
navigate(artikelQueryPath());
return;
}
if (segment === 'kategorien') {
navigate(defaultCategoryPath());
return;
}
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
width: 80,
height: 80,
borderRadius: '50%',
color: active
? theme.palette.primary.main
: theme.palette.text.secondary,
bgcolor: 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
})}
>
<Icon sx={{ fontSize: 40 }} />
</IconButton>
);
})}
</Box>
);
}
}
export default function AppShell() {
const navigate = useNavigate();
const { pathname } = useLocation();
const activeNav = resolveActiveNav(pathname);
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
return (
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'background.default' }}>
<AppSidebar activeNav={activeNav} navigate={navigate} />
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
display: 'flex',
gap: 2,
py: 4,
px: 3,
bgcolor: theme.nav.mainBackground,
minWidth: 0,
})}
>
<Outlet />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Outlet />
</Box>
</Box>
</Box>
);

View File

@@ -1,95 +1,23 @@
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { useLocation, useNavigate } from 'react-router-dom';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath, isKategorienPath } from '../pages/artikel/categoryRoute';
const SUB_NAV_WIDTH = 80;
function isSubNavActive(pathname, basePath, segment) {
if (!segment) {
if (isKategorienPath(pathname)) return false;
return pathname === basePath || pathname === `${basePath}/`;
}
if (segment === 'kategorien') {
return isKategorienPath(pathname);
}
const subPath = `${basePath}/${segment}`;
return pathname === subPath || pathname === `${subPath}/`;
}
export default function PageLayout({ title, showTitle = true, titleAction, subNav, basePath, children }) {
const hasSubNav = subNav?.length > 0;
const navigate = useNavigate();
const { pathname } = useLocation();
export default function PageLayout({
title,
showTitle = true,
titleAction,
children,
}) {
return (
<Box sx={{ display: 'flex', gap: 2, minHeight: 0 }}>
<Box
component={hasSubNav ? 'nav' : 'div'}
aria-label={hasSubNav ? 'Unternavigation' : undefined}
sx={{
width: SUB_NAV_WIDTH,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
{hasSubNav &&
subNav.map(({ segment, label, icon: Icon }) => {
const active = isSubNavActive(pathname, basePath, segment);
return (
<IconButton
key={segment || 'index'}
type="button"
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && basePath === '/artikel') {
navigate(artikelQueryPath());
return;
}
if (segment === 'kategorien') {
navigate(defaultCategoryPath());
return;
}
navigate(segment ? `${basePath}/${segment}` : basePath);
}}
sx={(theme) => ({
width: 80,
height: 80,
borderRadius: '50%',
color: active ? theme.palette.primary.main : theme.palette.text.secondary,
bgcolor: 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
})}
>
<Icon sx={{ fontSize: 40 }} />
</IconButton>
);
})}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{showTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{titleAction}
</Box>
) : null}
{children}
</Box>
<Box sx={{ minWidth: 0 }}>
{showTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{titleAction}
</Box>
) : null}
{children}
</Box>
);
}

View File

@@ -10,7 +10,7 @@ import ListItemText from '@mui/material/ListItemText';
import GetAppIcon from '@mui/icons-material/GetApp';
import SettingsIcon from '@mui/icons-material/Settings';
import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV } from '../nav';
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
import { isKategorienPath } from '../pages/artikel/categoryRoute';
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
import { ActionElevatedPill, ToggleElevatedPill } from './ElevatedPill';
@@ -34,13 +34,8 @@ export default function SectionLayout({ sectionPath }) {
const [extrasAnchor, setExtrasAnchor] = useState(null);
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
const subNav = SUB_NAV[sectionPath];
const activeSub = subNav?.find((item) => {
if (item.segment === 'kategorien') {
return isKategorienPath(pathname);
}
const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath;
return pathname === subPath || pathname === `${subPath}/`;
});
const subSegment = resolveActiveSub(pathname, sectionPath);
const activeSub = subNav?.find((item) => item.segment === subSegment);
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0];
@@ -107,8 +102,6 @@ export default function SectionLayout({ sectionPath }) {
<PageLayout
title={pageTitle}
showTitle={!isArticleDetail}
subNav={subNav}
basePath={sectionPath}
titleAction={titleAction}
>
<Outlet />

View File

@@ -1,4 +1,4 @@
import { Component, createRef } from 'react';
import { PureComponent, createRef } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { StorageManagerContext } from '../../api/storageManagerContext';
@@ -11,7 +11,7 @@ import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetai
import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends Component {
class ArticlePhotoTile extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tab from '@mui/material/Tab';
@@ -69,7 +69,7 @@ function visibleTabs(article) {
return tabs;
}
class ArticleTileTab extends Component {
class ArticleTileTab extends PureComponent {
constructor(props) {
super(props);
this.state = {

View File

@@ -20,6 +20,9 @@ import ArticleViewEANs from './boxes/ArticleViewEANs';
import ArticleViewPrice from './boxes/ArticleViewPrice';
import ArticleViewLists from './boxes/ArticleViewLists';
/** Stable prop so PureComponent photo tile does not see a new sx each render. */
const PHOTO_TILE_SX = { minHeight: 0 };
class ArticleView extends Component {
static contextType = StorageManagerContext;
@@ -27,10 +30,14 @@ class ArticleView extends Component {
super(props, context);
const { article, unwatch } = context.article.watch(
props.sku,
(next) => {this.setState({ article: next });},
(next) => {
this.setState({ article: next });
},
);
this.state = { article };
this.unwatchArticle = unwatch;
this.handleTabChange = this.handleTabChange.bind(this);
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
}
componentDidUpdate(prevProps) {
@@ -60,7 +67,7 @@ class ArticleView extends Component {
this.unwatchArticle = null;
}
handleArticleDeleted = () => {
handleArticleDeleted() {
const { navigate } = this.props;
const { article } = this.state;
const categoryState = this.context.getCategoryState();
@@ -72,13 +79,13 @@ class ArticleView extends Component {
return;
}
navigate(defaultCategoryPath());
};
}
handleTabChange = (nextTab) => {
handleTabChange(nextTab) {
const { navigate } = this.props;
const { article } = this.state;
navigate(artikelDetailPath(article, nextTab));
};
}
render() {
const { tab } = this.props;
@@ -99,7 +106,7 @@ class ArticleView extends Component {
<ArticlePhotoTile
wide={1}
color="#fde68a"
sx={{ minHeight: 0 }}
sx={PHOTO_TILE_SX}
article={article}
/>

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
@@ -71,7 +71,7 @@ class BrandEdit extends Component {
}
}
export default class ArticleViewBrand extends Component {
export default class ArticleViewBrand extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
@@ -83,7 +83,7 @@ class EansEdit extends Component {
}
}
export default class ArticleViewEANs extends Component {
export default class ArticleViewEANs extends PureComponent {
static contextType = StorageManagerContext;
handleSave = async (draft) => {

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
@@ -35,7 +35,7 @@ class RelationLine extends Component {
}
}
export default class ArticleViewLists extends Component {
export default class ArticleViewLists extends PureComponent {
render() {
const { article: articleData } = this.props;
return (

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { StorageManagerContext } from '../../../api/storageManagerContext';
@@ -224,7 +224,7 @@ class NameEdit extends Component {
}
}
export default class ArticleViewName extends Component {
export default class ArticleViewName extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
@@ -364,7 +364,7 @@ class PriceEdit extends Component {
}
}
export default class ArticleViewPrice extends Component {
export default class ArticleViewPrice extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { PanelTile } from '../../panels';
@@ -25,7 +25,7 @@ class PanelValue extends Component {
const SECOND_ROW_MIN_HEIGHT = 220;
export default class ArticleViewStock extends Component {
export default class ArticleViewStock extends PureComponent {
render() {
const { article: articleData } = this.props;
return (