Compare commits

..

4 Commits

Author SHA1 Message Date
seb
40416cdae5 u 2026-10-08 20:59:40 +02:00
seb
ca4033b626 u 2026-10-08 20:57:34 +02:00
seb
90c502071a u 2026-10-08 20:55:36 +02:00
seb
fa6d910723 u 2026-10-08 20:12:37 +02:00
7 changed files with 249 additions and 171 deletions

View File

@@ -4,7 +4,7 @@ import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { getCategoryState } from '../../../automats/categoryAutomat'; import { getCategoryState } from '../../../automats/categoryAutomat';
import artikelAutomat from '../../../automats/artikelAutomat'; import artikelAutomat from '../../../automats/artikelAutomat';
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils'; import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton } from '@mui/material'; import { Box, Chip, IconButton, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog'; import CategoryPickerDialog from '../CategoryPickerDialog';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
@@ -66,8 +66,9 @@ function categoryIdsChanged(a, b) {
function draftDirty(draft, saved) { function draftDirty(draft, saved) {
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim(); const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const seoDiff = String(draft.seo ?? '').trim() !== String(saved.seo ?? '').trim();
const catDiff = categoryIdsChanged(draft.categoryIds, saved.categoryIds); const catDiff = categoryIdsChanged(draft.categoryIds, saved.categoryIds);
return nameDiff || catDiff; return nameDiff || seoDiff || catDiff;
} }
function draftValid(draft) { function draftValid(draft) {
@@ -207,10 +208,10 @@ class CategoryPills extends Component {
class NameDisplay extends Component { class NameDisplay extends Component {
render() { render() {
const { name, categoryIds } = this.props; const { name, seo, categoryIds } = this.props;
return ( return (
<Box sx={columnSx}> <Box sx={columnSx}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}> <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<SingleLineTextEdit <SingleLineTextEdit
label="Name" label="Name"
value={name} value={name}
@@ -218,6 +219,13 @@ class NameDisplay extends Component {
onChange={() => {}} onChange={() => {}}
sx={articleNameFieldHiddenSx} sx={articleNameFieldHiddenSx}
/> />
{seo ? (
<Box sx={{ px: 1.75, mt: -0.5, userSelect: 'text' }}>
<Typography variant="body2" color="text.secondary">
{seo}
</Typography>
</Box>
) : null}
</Box> </Box>
{(categoryIds?.length ?? 0) > 0 ? ( {(categoryIds?.length ?? 0) > 0 ? (
<CategoryPills categoryIds={categoryIds} editMode={false} /> <CategoryPills categoryIds={categoryIds} editMode={false} />
@@ -232,13 +240,15 @@ class NameEdit extends Component {
super(props); super(props);
this.state = { this.state = {
name: props.name, name: props.name,
seo: props.seo ?? '',
categoryIds: [...(props.categoryIds ?? [])], categoryIds: [...(props.categoryIds ?? [])],
}; };
} }
componentDidMount() { componentDidMount() {
const { onNameDraftChange, onCategoryIdsDraftChange } = this.props; const { onNameDraftChange, onSeoDraftChange, onCategoryIdsDraftChange } = this.props;
onNameDraftChange?.(this.state.name); onNameDraftChange?.(this.state.name);
onSeoDraftChange?.(this.state.seo);
onCategoryIdsDraftChange?.(this.state.categoryIds); onCategoryIdsDraftChange?.(this.state.categoryIds);
} }
@@ -247,22 +257,35 @@ class NameEdit extends Component {
this.props.onNameDraftChange?.(name); this.props.onNameDraftChange?.(name);
}; };
handleSeoChange = (seo) => {
this.setState({ seo });
this.props.onSeoDraftChange?.(seo);
};
handleCategoryIdsChange = (categoryIds) => { handleCategoryIdsChange = (categoryIds) => {
this.setState({ categoryIds }); this.setState({ categoryIds });
this.props.onCategoryIdsDraftChange?.(categoryIds); this.props.onCategoryIdsDraftChange?.(categoryIds);
}; };
render() { render() {
const { name, categoryIds } = this.state; const { name, seo, categoryIds } = this.state;
return ( return (
<Box sx={columnSx}> <Box sx={columnSx}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}> <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<SingleLineTextEdit <SingleLineTextEdit
label="Name" label="Name"
value={name} value={name}
onChange={this.handleNameChange} onChange={this.handleNameChange}
sx={articleNameFieldSx} sx={articleNameFieldSx}
/> />
<Box sx={{ mt: 1 }}>
<SingleLineTextEdit
label="SEO"
value={seo}
size="small"
onChange={this.handleSeoChange}
/>
</Box>
</Box> </Box>
<CategoryPills <CategoryPills
categoryIds={categoryIds} categoryIds={categoryIds}
@@ -279,6 +302,7 @@ export default class ArticleViewName extends PureComponent {
super(props); super(props);
this.state = { this.state = {
nameDraft: null, nameDraft: null,
seoDraft: null,
categoryIdsDraft: null, categoryIdsDraft: null,
}; };
} }
@@ -287,23 +311,33 @@ export default class ArticleViewName extends PureComponent {
this.setState({ nameDraft: name }); this.setState({ nameDraft: name });
}; };
handleSeoDraftChange = (seo) => {
this.setState({ seoDraft: seo });
};
handleCategoryIdsDraftChange = (categoryIds) => { handleCategoryIdsDraftChange = (categoryIds) => {
this.setState({ categoryIdsDraft: categoryIds }); this.setState({ categoryIdsDraft: categoryIds });
}; };
handleSave = async () => { handleSave = async () => {
const { article: articleData, onArticleUpdated } = this.props; const { article: articleData, onArticleUpdated } = this.props;
const { nameDraft, categoryIdsDraft } = this.state; const { nameDraft, seoDraft, categoryIdsDraft } = this.state;
const name = nameDraft.trim(); const name = nameDraft != null ? nameDraft.trim() : String(articleData.name ?? '').trim();
const categoryIds = categoryIdsDraft; const seo = seoDraft != null ? seoDraft.trim() : String(articleData.seo ?? articleData.seoDescription ?? '').trim();
const categoryIds = categoryIdsDraft ?? (articleData.categoryIds ?? []);
const nameChanged = name !== String(articleData.name ?? '').trim(); const nameChanged = name !== String(articleData.name ?? '').trim();
const seoChanged = seo !== String(articleData.seo ?? articleData.seoDescription ?? '').trim();
const categoriesChanged = categoryIdsChanged(categoryIds, articleData.categoryIds); const categoriesChanged = categoryIdsChanged(categoryIds, articleData.categoryIds);
let article = null; let article = null;
if (nameChanged) { if (nameChanged || seoChanged) {
const nameRes = await artikelAutomat.actions.update(articleData.sku, { name }); const updateBody = {};
article = nameRes.article; if (nameChanged) updateBody.name = name;
if (seoChanged) updateBody.seo = seo;
const res = await artikelAutomat.actions.update(articleData.sku, updateBody);
article = res.article;
} }
if (categoriesChanged) { if (categoriesChanged) {
@@ -312,17 +346,26 @@ export default class ArticleViewName extends PureComponent {
} }
if (article) onArticleUpdated?.(article); if (article) onArticleUpdated?.(article);
this.setState({ nameDraft: null, categoryIdsDraft: null }); this.setState({ nameDraft: null, seoDraft: null, categoryIdsDraft: null });
}; };
render() { render() {
const { article: articleData } = this.props; const { article: articleData } = this.props;
const { nameDraft, categoryIdsDraft } = this.state; const { nameDraft, seoDraft, categoryIdsDraft } = this.state;
const savedCategoryIds = articleData.categoryIds ?? []; const savedCategoryIds = articleData.categoryIds ?? [];
const value = { name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }; const savedSeo = articleData.seo ?? articleData.seoDescription ?? '';
const value = {
name: articleData.name ?? '',
seo: savedSeo,
categoryIds: [...savedCategoryIds],
};
const draft = const draft =
nameDraft != null && categoryIdsDraft != null nameDraft != null && categoryIdsDraft != null
? { name: nameDraft, categoryIds: categoryIdsDraft } ? {
name: nameDraft,
seo: seoDraft ?? savedSeo,
categoryIds: categoryIdsDraft,
}
: null; : null;
return ( return (
@@ -339,14 +382,17 @@ export default class ArticleViewName extends PureComponent {
display={ display={
<NameDisplay <NameDisplay
name={value.name} name={value.name}
seo={value.seo}
categoryIds={value.categoryIds} categoryIds={value.categoryIds}
/> />
} }
edit={ edit={
<NameEdit <NameEdit
name={value.name} name={value.name}
seo={value.seo}
categoryIds={value.categoryIds} categoryIds={value.categoryIds}
onNameDraftChange={this.handleNameDraftChange} onNameDraftChange={this.handleNameDraftChange}
onSeoDraftChange={this.handleSeoDraftChange}
onCategoryIdsDraftChange={this.handleCategoryIdsDraftChange} onCategoryIdsDraftChange={this.handleCategoryIdsDraftChange}
/> />
} }

View File

@@ -8,7 +8,7 @@ import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import unitAutomat from '../../../automats/unitAutomat'; import unitAutomat from '../../../automats/unitAutomat';
import companyAutomat from '../../../automats/companyAutomat'; import companyAutomat from '../../../automats/companyAutomat';
import artikelAutomat from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { import {
buildPosPricesPutBody, buildPosPricesPutBody,
@@ -19,6 +19,7 @@ import {
} from '../../../utils/articleDetail'; } from '../../../utils/articleDetail';
import { import {
formatPriceAmount, formatPriceAmount,
getArticleGross,
netFromGross, netFromGross,
parsePriceInput, parsePriceInput,
} from '../../../utils/price'; } from '../../../utils/price';
@@ -33,7 +34,6 @@ import TierPricePills from './TierPricePills';
import { PriceUnitRow, PriceUnitCorner } from './PriceUnitRow'; import { PriceUnitRow, PriceUnitCorner } from './PriceUnitRow';
import { import {
BLUE_PANEL, BLUE_PANEL,
BORDER,
} from '../../../colors'; } from '../../../colors';
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
@@ -387,6 +387,20 @@ export default class ArticleViewPrice extends PureComponent {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
company: state.current ?? null, company: state.current ?? null,
})); }));
this.unsubArtikel = artikelAutomat.subscribe(this, () => ({}));
if (this.props.article?.isParent) {
this.ensureChildren(this.props.article.children);
}
}
componentDidUpdate(prevProps) {
if (
this.props.article?.isParent &&
(prevProps.article?.sku !== this.props.article?.sku ||
prevProps.article?.children !== this.props.article?.children)
) {
this.ensureChildren(this.props.article.children);
}
} }
componentWillUnmount() { componentWillUnmount() {
@@ -395,6 +409,17 @@ export default class ArticleViewPrice extends PureComponent {
this.unsubUnits = null; this.unsubUnits = null;
this.unsubCompany?.(); this.unsubCompany?.();
this.unsubCompany = null; this.unsubCompany = null;
this.unsubArtikel?.();
this.unsubArtikel = null;
}
ensureChildren(children) {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
}
}
} }
handleSave = async (draft) => { handleSave = async (draft) => {
@@ -481,11 +506,33 @@ export default class ArticleViewPrice extends PureComponent {
const { article: articleData } = this.props; const { article: articleData } = this.props;
if (articleData?.isParent) { if (articleData?.isParent) {
const { company } = this.state;
const defaultTaxRate = company?.taxrate ?? 19;
const children = Array.isArray(articleData.children) ? articleData.children : [];
const prices = [];
for (const child of children) {
const fullChild = getArticle(child.sku) || child;
const gross = getArticleGross(fullChild, defaultTaxRate);
if (gross != null && Number.isFinite(gross)) {
prices.push(gross);
}
}
let priceText = 'Kein Preis';
if (prices.length > 0) {
const min = Math.min(...prices);
const max = Math.max(...prices);
priceText =
min === max
? `€ ${formatPriceAmount(min)}`
: `€ ${formatPriceAmount(min)} bis € ${formatPriceAmount(max)}`;
}
return ( return (
<PanelTile <PanelTile
wide={1} wide={1}
minHeight={SECOND_ROW_MIN_HEIGHT} minHeight={SECOND_ROW_MIN_HEIGHT}
color={BORDER} color={BLUE_PANEL}
contentAlignItems="flex-start" contentAlignItems="flex-start"
> >
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
@@ -493,32 +540,25 @@ export default class ArticleViewPrice extends PureComponent {
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ sx={{
textTransform: 'uppercase',
letterSpacing: 0.6, letterSpacing: 0.6,
fontWeight: 600, fontWeight: 600,
display: 'block', display: 'block',
}} }}
> >
Preis Preis auf Variantenebene pflegen
</Typography> </Typography>
<Box sx={{ my: 0.75 }}> <Box sx={{ my: 0.75 }}>
<Typography <Typography
component="div" component="div"
sx={{ sx={{
fontSize: '1.25rem', fontSize: '1.5rem',
fontWeight: 700, fontWeight: 700,
lineHeight: 1.2, lineHeight: 1.2,
color: 'text.secondary', letterSpacing: '-0.02em',
color: 'text.primary',
}} }}
> >
Kein Preis {priceText}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25 }}
>
Preise werden auf Variantenebene gepflegt
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@@ -7,8 +7,8 @@ import Tooltip from '@mui/material/Tooltip';
import { PanelTile } from 'dashgui/panels'; import { PanelTile } from 'dashgui/panels';
import { formatStock } from '../searchFormat'; import { formatStock } from '../searchFormat';
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog'; import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { import {
BORDER,
GREEN_PANEL, GREEN_PANEL,
PETROL_PANEL, PETROL_PANEL,
SORT_GHOST_BG, SORT_GHOST_BG,
@@ -28,6 +28,36 @@ export default class ArticleViewStock extends PureComponent {
this.handleCloseAdjust = this.handleCloseAdjust.bind(this); this.handleCloseAdjust = this.handleCloseAdjust.bind(this);
} }
componentDidMount() {
this.unsubArtikel = artikelAutomat.subscribe(this, () => ({}));
if (this.props.article?.isParent) {
this.ensureChildren(this.props.article.children);
}
}
componentDidUpdate(prevProps) {
if (
this.props.article?.isParent &&
(prevProps.article?.sku !== this.props.article?.sku ||
prevProps.article?.children !== this.props.article?.children)
) {
this.ensureChildren(this.props.article.children);
}
}
componentWillUnmount() {
this.unsubArtikel?.();
}
ensureChildren(children) {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
}
}
}
handleOpenAdjust() { handleOpenAdjust() {
this.setState({ adjustOpen: true }); this.setState({ adjustOpen: true });
} }
@@ -54,6 +84,22 @@ export default class ArticleViewStock extends PureComponent {
const warehouses = rawWarehouses.filter((wh) => (Number(wh.stock) || 0) > 0); const warehouses = rawWarehouses.filter((wh) => (Number(wh.stock) || 0) > 0);
const hasWarehouses = warehouses.length > 0; const hasWarehouses = warehouses.length > 0;
let totalVariantsStock = 0;
if (isParent) {
const children = Array.isArray(articleData?.children) ? articleData.children : [];
for (const child of children) {
const fullChild = getArticle(child.sku) || child;
if (Array.isArray(fullChild.warehouses) && fullChild.warehouses.length > 0) {
totalVariantsStock += fullChild.warehouses.reduce(
(sum, wh) => sum + (Number(wh.stock) || 0),
0,
);
} else if (fullChild.stock != null) {
totalVariantsStock += Number(fullChild.stock) || 0;
}
}
}
const totalStock = rawWarehouses.length > 0 const totalStock = rawWarehouses.length > 0
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0) ? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: articleData?.stock != null : articleData?.stock != null
@@ -74,7 +120,7 @@ export default class ArticleViewStock extends PureComponent {
<PanelTile <PanelTile
wide={1} wide={1}
minHeight={SECOND_ROW_MIN_HEIGHT} minHeight={SECOND_ROW_MIN_HEIGHT}
color={isParent ? BORDER : isStueckliste ? PETROL_PANEL : GREEN_PANEL} color={isStueckliste ? PETROL_PANEL : GREEN_PANEL}
action={ action={
isParent || isStueckliste isParent || isStueckliste
? null ? null
@@ -99,13 +145,13 @@ export default class ArticleViewStock extends PureComponent {
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ sx={{
textTransform: 'uppercase', ...(isParent ? {} : { textTransform: 'uppercase' }),
letterSpacing: 0.6, letterSpacing: 0.6,
fontWeight: 600, fontWeight: 600,
display: 'block', display: 'block',
}} }}
> >
Bestand {isParent ? 'Bestand auf Variantenebene pflegen' : 'Bestand'}
</Typography> </Typography>
{isParent ? ( {isParent ? (
@@ -116,17 +162,10 @@ export default class ArticleViewStock extends PureComponent {
fontSize: '1.25rem', fontSize: '1.25rem',
fontWeight: 700, fontWeight: 700,
lineHeight: 1.2, lineHeight: 1.2,
color: 'text.secondary', color: 'text.primary',
}} }}
> >
Kein Bestand Alle Varianten zusammen: {formatStock(totalVariantsStock)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25 }}
>
Bestand wird auf Variantenebene geführt
</Typography> </Typography>
</Box> </Box>
) : isStueckliste ? ( ) : isStueckliste ? (

View File

@@ -1,37 +1,10 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
function PanelValue({ label, children }) { function PanelHtmlValue({ html }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function PanelHtmlValue({ label, html }) {
if (html == null || html === '') return null; if (html == null || html === '') return null;
return ( return (
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
{label ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 0.5, fontWeight: 500 }}
>
{label}
</Typography>
) : null}
<Box <Box
sx={(theme) => ({ sx={(theme) => ({
p: 1.5, p: 1.5,
@@ -83,14 +56,7 @@ function PanelHtmlValue({ label, html }) {
export default class ArticleSeoTab extends Component { export default class ArticleSeoTab extends Component {
render() { render() {
const { article: articleData } = this.props; const { article: articleData } = this.props;
const seo = articleData.seo ?? articleData.seoDescription;
const desc = articleData.desc ?? articleData.description; const desc = articleData.desc ?? articleData.description;
return ( return <PanelHtmlValue html={desc} />;
<>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO">{seo}</PanelValue>
<PanelHtmlValue label="Beschreibung" html={desc} />
</>
);
} }
} }

View File

@@ -245,6 +245,22 @@ export default class BrandImageSearchDialog extends PureComponent {
fullWidth fullWidth
scroll="paper" scroll="paper"
aria-labelledby="brand-image-search-dialog-title" aria-labelledby="brand-image-search-dialog-title"
PaperProps={{
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
},
}}
slotProps={{
paper: {
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
},
},
}}
> >
<DialogTitle <DialogTitle
id="brand-image-search-dialog-title" id="brand-image-search-dialog-title"
@@ -286,12 +302,10 @@ export default class BrandImageSearchDialog extends PureComponent {
> >
<TextInput <TextInput
fullWidth fullWidth
label="Suchbegriff"
value={query} value={query}
disabled={isSearching || Boolean(processingId)} disabled={isSearching || Boolean(processingId)}
onChange={(e) => this.setState({ query: e.target.value })} onChange={(e) => this.setState({ query: e.target.value })}
onKeyDown={this.handleQueryKeyDown} onKeyDown={this.handleQueryKeyDown}
placeholder="z. B. Nike logo png"
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@@ -319,58 +333,80 @@ export default class BrandImageSearchDialog extends PureComponent {
</Button> </Button>
</Box> </Box>
{/* User helper tip */}
{images.length > 0 && !error && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 1.5,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
}}
>
<AutoFixHighIcon color="primary" sx={{ fontSize: 17 }} />
<Typography variant="caption" color="text.secondary">
Klicken Sie auf ein Logo: Es wird heruntergeladen, automatisch freigestellt (Weiß- &amp; Transparenz-Ränder entfernt), in AVIF konvertiert und als Markenbild hinterlegt.
</Typography>
</Box>
)}
{/* Progress / Status banner */} {/* Progress / Status banner */}
{status === 'loading' && <LinearProgress sx={{ mb: 2, borderRadius: 1 }} />} <Box
sx={{
minHeight: 28,
mb: 1.5,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{status === 'loading' && (
<Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography variant="caption" color="text.secondary">
Bilder werden gesucht...
</Typography>
</Box>
<LinearProgress sx={{ borderRadius: 1 }} />
</Box>
)}
{status === 'streaming' && ( {status === 'streaming' && (
<Box sx={{ mb: 2 }}> <Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography variant="caption" color="text.secondary">
Thumbnails werden gestreamt...
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{loadedThumbs} / {images.length}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={
images.length > 0
? Math.round((loadedThumbs / images.length) * 100)
: 0
}
sx={{ borderRadius: 1 }}
/>
</Box>
)}
{status === 'done' && images.length > 0 && (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
mb: 0.5,
}} }}
> >
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Thumbnails werden gestreamt... Thumbnails geladen
</Typography> </Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary"> <Typography variant="caption" fontWeight={600} color="text.secondary">
{loadedThumbs} / {images.length} {loadedThumbs} / {images.length}
</Typography> </Typography>
</Box> </Box>
<LinearProgress )}
variant="determinate" </Box>
value={
images.length > 0
? Math.round((loadedThumbs / images.length) * 100)
: 0
}
sx={{ borderRadius: 1 }}
/>
</Box>
)}
{/* Error banner */} {/* Error banner */}
{error && ( {error && (
@@ -616,10 +652,7 @@ export default class BrandImageSearchDialog extends PureComponent {
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5, justifyContent: 'space-between' }}> <DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{images.length > 0 ? `${images.length} Ergebnisse gefunden` : ''}
</Typography>
<Button onClick={onClose} color="inherit"> <Button onClick={onClose} color="inherit">
Schließen Schließen
</Button> </Button>

View File

@@ -16,52 +16,6 @@ body {
touch-action: manipulation; touch-action: manipulation;
} }
:root {
--df-zoom: 1;
--df-vh: 100vh;
}
/* ────────────────────────────────────────────────────────────────────────────
* RESPONSIVE VIEWPORT ZOOM
*
* Automatically scales the UI down on narrower viewports:
* - < 1700px: 80% zoom
* - < 1400px: 65% zoom
* - < 1000px: 50% zoom
*
* `--df-vh` scales inversely with zoom so that elements with full-screen
* height (like AppSidebar and full-height page wrappers) always reach the
* true bottom of the viewport instead of shrinking.
* ──────────────────────────────────────────────────────────────────────────── */
@media (max-width: 1699.98px) {
:root {
--df-zoom: 0.8;
--df-vh: calc(100vh / 0.8);
}
body {
zoom: 0.8;
}
}
@media (max-width: 1399.98px) {
:root {
--df-zoom: 0.65;
--df-vh: calc(100vh / 0.65);
}
body {
zoom: 0.65;
}
}
@media (max-width: 999.98px) {
:root {
--df-zoom: 0.5;
--df-vh: calc(100vh / 0.5);
}
body {
zoom: 0.5;
}
}
/* ──────────────────────────────────────────────────────────────────────────── /* ────────────────────────────────────────────────────────────────────────────
* SYSTEM-WIDE SPELLCHECK OFF * SYSTEM-WIDE SPELLCHECK OFF

View File

@@ -53,7 +53,7 @@ createRoot(document.getElementById('root')).render(
<AuthBootstrap> <AuthBootstrap>
{/* Flex row so the dev inspector docks beside the app instead of {/* Flex row so the dev inspector docks beside the app instead of
overlaying it; the app content shrinks while the panel is open. */} overlaying it; the app content shrinks while the panel is open. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: 'var(--df-vh, 100vh)' }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
<App /> <App />
</Box> </Box>