This commit is contained in:
seb
2026-10-11 04:21:26 +02:00
parent 5e8037d430
commit 83dc698322
8 changed files with 890 additions and 671 deletions

View File

@@ -20,7 +20,6 @@ import {
NAV_SURFACE,
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
import PageMinimap from './PageMinimap';
const SIDEBAR_WIDTH = 144;
@@ -323,7 +322,6 @@ export default function AppShell() {
</Box>
<ChatFab />
<ChatDrawer />
<PageMinimap />
</Box>
);
}

View File

@@ -1,10 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import MapRoundedIcon from '@mui/icons-material/MapRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
const MINIMAP_WIDTH = 220;
const MINIMAP_MAX_HEIGHT = 300;
@@ -42,16 +40,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
const idleTimerRef = useRef(null);
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
// SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
// default tab resolves to seo.
const { pathname } = useLocation();
const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
const isSeoPage = Boolean(
articleMatch &&
(articleMatch.params?.tab === ARTICLE_TAB.seo ||
(!articleMatch.params?.tab &&
readPreferredArticleTab() === ARTICLE_TAB.seo)),
);
/** Move the viewport rectangle without re-rendering (called on scroll). */
const updateViewport = useCallback(() => {
@@ -160,11 +148,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
if (enabled && open) rebuild();
}, [enabled, open, rebuild]);
// Reset scroll visibility on navigation; the new page starts hidden
// until the user scrolls again.
useEffect(() => {
setScrollActive(false);
}, [pathname]);
useEffect(() => {
rebuild();
@@ -214,7 +197,7 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
const visible = scrollActive || hovering || dragging;
if (!enabled || !isSeoPage) return null;
if (!enabled) return null;
// Collapsed: a single centered pill to reopen the overview.
if (!open) {

View File

@@ -1,5 +1,6 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import PageMinimap from '../../PageMinimap';
function PanelHtmlValue({ html }) {
if (html == null || html === '') return null;
@@ -57,6 +58,11 @@ export default class ArticleSeoTab extends Component {
render() {
const { article: articleData } = this.props;
const desc = articleData.desc ?? articleData.description;
return <PanelHtmlValue html={desc} />;
return (
<>
<PanelHtmlValue html={desc} />
<PageMinimap />
</>
);
}
}

View File

@@ -0,0 +1,227 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack';
import attributeAutomat from '../../automats/attributeAutomat';
import { notifyError } from '../../utils/notify';
import {
SELECT_BG,
SELECT_BG_HOVER,
SELECT_BORDER,
TINT_SUBTLE,
} from '../../colors';
class ArtikelMerkmalAuswahl extends PureComponent {
constructor(props) {
super(props);
this.state = {
draft: props.attribute?.name ?? '',
busy: false,
};
}
componentDidUpdate(prevProps) {
if (prevProps.attribute?.name !== this.props.attribute?.name) {
this.setState({ draft: this.props.attribute?.name ?? '' });
} else if (prevProps.disabled && !this.props.disabled) {
if (this.state.draft !== this.props.attribute?.name) {
this.setState({ draft: this.props.attribute?.name ?? '' });
}
}
}
handleChange = (next) => {
this.setState({ draft: next });
this.props.onChange?.(next, this.props.attribute);
};
handleBlur = () => {
const { attribute } = this.props;
if (!attribute) return;
const draft = this.state.draft.trim();
if (!draft) {
this.setState({ draft: attribute.name ?? '' });
return;
}
if (draft !== attribute.name) {
void this.renameAttribute(attribute, draft);
}
};
async renameAttribute(attribute, name) {
this.setState({ busy: true });
try {
await attributeAutomat.actions.updateAttribute(attribute.id, { name });
enqueueSnackbar('Attribut gespeichert.', { variant: 'success' });
} catch (err) {
this.setState({ draft: attribute.name ?? '' });
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
handleKeyDown = (event) => {
const { attribute } = this.props;
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState({ draft: attribute?.name ?? '' });
event.target.blur();
}
};
handleToggleExclusive = async (event) => {
event.stopPropagation();
const { attribute, disabled } = this.props;
if (!attribute || disabled || this.state.busy) return;
const nextExclusive = !attribute.exclusive;
this.setState({ busy: true });
try {
await attributeAutomat.actions.updateAttribute(attribute.id, { exclusive: nextExclusive });
enqueueSnackbar(
`Attribut „${attribute.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
{ variant: 'info' }
);
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
handleDelete = (event) => {
event.stopPropagation();
const { attribute, disabled } = this.props;
if (!attribute || disabled || this.state.busy) return;
if (!window.confirm(`Attribut „${attribute.name}“ und alle zugehörigen Werte wirklich löschen?`)) {
return;
}
this.setState({ busy: true });
void (async () => {
try {
await attributeAutomat.actions.deleteAttribute(attribute.id);
enqueueSnackbar('Attribut gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
this.setState({ busy: false });
}
})();
};
render() {
const { attribute, isSelected, onSelect, disabled, sx } = this.props;
const { draft, busy } = this.state;
const name = attribute?.name ?? '';
const exclusive = attribute ? Boolean(attribute.exclusive) : true;
const isDisabled = disabled || busy;
return (
<ListItem
disableGutters
data-id={attribute?.id}
onClick={onSelect}
sx={{
gap: 1,
py: 0.75,
px: 0.5,
borderRadius: 1.5,
alignItems: 'center',
width: '100%',
minWidth: 0,
cursor: 'pointer',
bgcolor: isSelected ? SELECT_BG : 'transparent',
border: isSelected ? `1px solid ${SELECT_BORDER}` : '1px solid transparent',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isSelected ? SELECT_BG_HOVER : TINT_SUBTLE,
},
...sx,
}}
>
<Box
className="drag-handle"
onClick={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
flexShrink: 0,
color: 'text.disabled',
cursor: isDisabled ? 'default' : 'grab',
userSelect: 'none',
touchAction: 'none',
'&:hover': isDisabled ? undefined : { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon sx={{ fontSize: 24, pointerEvents: 'none' }} />
</Box>
<Box onClick={(e) => e.stopPropagation()} sx={{ flex: 1, minWidth: 0 }}>
<SingleLineTextEdit
size="small"
label=""
value={draft}
disabled={isDisabled || !attribute}
onChange={this.handleChange}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
sx={{ width: '100%' }}
/>
</Box>
<Chip
label="Multi"
size="small"
color={exclusive ? 'default' : 'success'}
variant="filled"
onClick={this.handleToggleExclusive}
aria-pressed={!exclusive}
sx={{
height: 24,
width: 52,
minWidth: 52,
maxWidth: 52,
fontSize: '0.75rem',
fontWeight: 600,
cursor: isDisabled ? 'default' : 'pointer',
flexShrink: 0,
...(exclusive
? { bgcolor: 'grey.300', color: 'text.secondary' }
: null),
'& .MuiChip-label': {
px: 0.5,
textAlign: 'center',
width: '100%',
},
}}
/>
<IconButton
aria-label={`„${name}“ löschen`}
disabled={isDisabled || !attribute}
onClick={this.handleDelete}
sx={{ width: 40, height: 40, flexShrink: 0 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</ListItem>
);
}
}
export default ArtikelMerkmalAuswahl;

View File

@@ -0,0 +1,212 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack';
import propertyAutomat from '../../automats/propertyAutomat';
import { notifyError } from '../../utils/notify';
class ArtikelMerkmalText extends PureComponent {
constructor(props) {
super(props);
const item = props.property ?? props.item;
this.state = {
draft: item?.name ?? '',
busy: false,
};
}
componentDidUpdate(prevProps) {
const prevItem = prevProps.property ?? prevProps.item;
const item = this.props.property ?? this.props.item;
if (prevItem?.name !== item?.name) {
this.setState({ draft: item?.name ?? '' });
} else if (prevProps.disabled && !this.props.disabled) {
if (this.state.draft !== item?.name) {
this.setState({ draft: item?.name ?? '' });
}
}
}
handleChange = (next) => {
this.setState({ draft: next });
const item = this.props.property ?? this.props.item;
this.props.onChange?.(next, item);
};
handleBlur = () => {
const item = this.props.property ?? this.props.item;
if (!item) return;
const draft = this.state.draft.trim();
if (!draft) {
this.setState({ draft: item.name ?? '' });
return;
}
if (draft !== item.name) {
void this.renameProperty(item, draft);
}
};
async renameProperty(property, name) {
this.setState({ busy: true });
try {
await propertyAutomat.actions.updateProperty(property.id, { name });
enqueueSnackbar('Merkmal gespeichert.', { variant: 'success' });
} catch (err) {
this.setState({ draft: property.name ?? '' });
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
handleKeyDown = (event) => {
const item = this.props.property ?? this.props.item;
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState({ draft: item?.name ?? '' });
event.target.blur();
}
};
handleToggleExclusive = async (event) => {
event.stopPropagation();
const item = this.props.property ?? this.props.item;
if (!item || this.props.disabled || this.state.busy) return;
const nextExclusive = !item.exclusive;
this.setState({ busy: true });
try {
await propertyAutomat.actions.updateProperty(item.id, { exclusive: nextExclusive });
enqueueSnackbar(
`Merkmal „${item.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
{ variant: 'info' }
);
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
handleDelete = () => {
const item = this.props.property ?? this.props.item;
if (!item || this.props.disabled || this.state.busy) return;
if (!window.confirm(`Merkmal „${item.name}“ wirklich löschen?`)) {
return;
}
this.setState({ busy: true });
void (async () => {
try {
await propertyAutomat.actions.deleteProperty(item.id);
enqueueSnackbar('Merkmal gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
this.setState({ busy: false });
}
})();
};
render() {
const { disabled, sx } = this.props;
const item = this.props.property ?? this.props.item;
const { draft, busy } = this.state;
const name = item?.name ?? '';
const exclusive = item ? Boolean(item.exclusive) : true;
const isDisabled = disabled || busy;
return (
<ListItem
disableGutters
data-id={item?.id}
sx={{
gap: 1,
py: 0.75,
alignItems: 'center',
width: '100%',
minWidth: 0,
...sx,
}}
>
<Box
className="drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
flexShrink: 0,
color: 'text.disabled',
cursor: isDisabled ? 'default' : 'grab',
userSelect: 'none',
touchAction: 'none',
'&:hover': isDisabled ? undefined : { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon sx={{ fontSize: 24, pointerEvents: 'none' }} />
</Box>
<SingleLineTextEdit
size="small"
label=""
value={draft}
disabled={isDisabled || !item}
onChange={this.handleChange}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
sx={{ flex: 1, minWidth: 0 }}
/>
<Chip
label="Multi"
size="small"
color={exclusive ? 'default' : 'success'}
variant="filled"
onClick={this.handleToggleExclusive}
aria-pressed={!exclusive}
sx={{
height: 24,
width: 52,
minWidth: 52,
maxWidth: 52,
fontSize: '0.75rem',
fontWeight: 600,
cursor: isDisabled ? 'default' : 'pointer',
flexShrink: 0,
...(exclusive
? { bgcolor: 'grey.300', color: 'text.secondary' }
: null),
'& .MuiChip-label': {
px: 0.5,
textAlign: 'center',
width: '100%',
},
}}
/>
<IconButton
aria-label={`„${name}“ löschen`}
disabled={isDisabled || !item}
onClick={this.handleDelete}
sx={{ width: 40, height: 40, flexShrink: 0 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</ListItem>
);
}
}
export default ArtikelMerkmalText;

View File

@@ -0,0 +1,163 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack';
import attributeAutomat from '../../automats/attributeAutomat';
import { notifyError } from '../../utils/notify';
class ArtikelMerkmalWert extends PureComponent {
constructor(props) {
super(props);
const item = props.item ?? props.value;
this.state = {
draft: item?.value ?? '',
busy: false,
};
}
componentDidUpdate(prevProps) {
const prevItem = prevProps.item ?? prevProps.value;
const item = this.props.item ?? this.props.value;
if (prevItem?.value !== item?.value) {
this.setState({ draft: item?.value ?? '' });
} else if (prevProps.disabled && !this.props.disabled) {
if (this.state.draft !== item?.value) {
this.setState({ draft: item?.value ?? '' });
}
}
}
handleChange = (next) => {
this.setState({ draft: next });
const item = this.props.item ?? this.props.value;
this.props.onChange?.(next, item);
};
handleBlur = () => {
const item = this.props.item ?? this.props.value;
if (!item) return;
const draft = this.state.draft.trim();
if (!draft) {
this.setState({ draft: item.value ?? '' });
return;
}
if (draft !== item.value) {
void this.renameValue(item, draft);
}
};
async renameValue(item, val) {
this.setState({ busy: true });
try {
await attributeAutomat.actions.updateValue(item.id, { value: val });
enqueueSnackbar('Wert gespeichert.', { variant: 'success' });
} catch (err) {
this.setState({ draft: item.value ?? '' });
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
handleKeyDown = (event) => {
const item = this.props.item ?? this.props.value;
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState({ draft: item?.value ?? '' });
event.target.blur();
}
};
handleDelete = () => {
const item = this.props.item ?? this.props.value;
if (!item || this.props.disabled || this.state.busy) return;
if (!window.confirm(`Wert „${item.value}“ wirklich löschen?`)) {
return;
}
this.setState({ busy: true });
void (async () => {
try {
await attributeAutomat.actions.deleteValue(item.id);
enqueueSnackbar('Wert gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
this.setState({ busy: false });
}
})();
};
render() {
const { disabled, sx } = this.props;
const item = this.props.item ?? this.props.value;
const { draft, busy } = this.state;
const valText = item?.value ?? '';
const isDisabled = disabled || busy;
return (
<ListItem
disableGutters
data-id={item?.id}
sx={{
gap: 1,
py: 0.75,
alignItems: 'center',
width: '100%',
minWidth: 0,
...sx,
}}
>
<Box
className="drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
flexShrink: 0,
color: 'text.disabled',
cursor: isDisabled ? 'default' : 'grab',
userSelect: 'none',
touchAction: 'none',
'&:hover': isDisabled ? undefined : { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon sx={{ fontSize: 24, pointerEvents: 'none' }} />
</Box>
<SingleLineTextEdit
size="small"
label=""
value={draft}
disabled={isDisabled || !item}
onChange={this.handleChange}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
sx={{ flex: 1, minWidth: 0 }}
/>
<IconButton
aria-label={`„${valText}“ löschen`}
disabled={isDisabled || !item}
onClick={this.handleDelete}
sx={{ width: 40, height: 40, flexShrink: 0 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</ListItem>
);
}
}
export default ArtikelMerkmalWert;

View File

@@ -0,0 +1,3 @@
export { default as ArtikelMerkmalAuswahl } from './ArtikelMerkmalAuswahl';
export { default as ArtikelMerkmalWert } from './ArtikelMerkmalWert';
export { default as ArtikelMerkmalText } from './ArtikelMerkmalText';

File diff suppressed because it is too large Load Diff