This commit is contained in:
seb
2026-10-10 18:17:14 +02:00
parent 004e0de869
commit a20668cd61
31 changed files with 572 additions and 76 deletions

View File

@@ -24,6 +24,11 @@ import categoryAutomat from '../../automats/categoryAutomat';
import articleIndexAutomat from '../../automats/articleIndexAutomat';
import brandAutomat from '../../automats/brandAutomat';
import { orderAutomat } from '../../automats/orderAutomat';
import SkeleHashImage from './SkeleHashImage';
// Keep only the most recent N image events in state to avoid unbounded growth
// on large imports (thousands of images).
const MAX_STREAMED_IMAGES = 60;
function formatBytes(bytes) {
if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B';
@@ -42,14 +47,18 @@ function isZipFile(file) {
);
}
const PHASE_LABELS = {
brands: 'Marken werden importiert…',
categories: 'Kategorien werden importiert…',
articles: 'Artikel werden abgeglichen…',
variants: 'Varianten werden verknüpft…',
images: 'Bilder werden optimiert…',
orders: 'Aufträge werden importiert…',
};
function shortHash(hash) {
if (typeof hash !== 'string') return '';
return hash.length > 10 ? `${hash.slice(0, 8)}…` : hash;
}
// Counter-based phases emitted by the `progress` event.
const PROGRESS_PHASES = [
{ key: 'categories', label: 'Kategorien', totalKey: 'categoriestotal', doneKey: 'categoriesdone' },
{ key: 'articles', label: 'Artikel', totalKey: 'articlestotal', doneKey: 'articlesdone' },
{ key: 'images', label: 'Medien', totalKey: 'imagestotal', doneKey: 'imagesdone' },
{ key: 'receipts', label: 'Belege', totalKey: 'receiptstotal', doneKey: 'receiptsdone' },
];
const EXPORT_OPTIONS = [
{
@@ -94,10 +103,10 @@ export default class ImportExportSettingsPanel extends Component {
uploadProgress: 0,
uploadLoaded: 0,
uploadTotal: 0,
importPhase: null, // 'categories' | 'articles' | 'variants' | 'images' | null
importPhaseCurrent: 0,
importPhaseTotal: 0,
importItem: null,
importProgress: null, // raw counters from the `progress` event
// Most recent SkeleHashes from the stream ({ id, hash })
importedImages: [],
importedImageCount: 0,
statusMessage: null,
importStats: null,
uploadError: null,
@@ -243,16 +252,16 @@ export default class ImportExportSettingsPanel extends Component {
this.uploadAbortController = new AbortController();
this.imageSeq = 0;
this.setState({
uploading: true,
uploadStage: 'upload',
uploadProgress: 0,
uploadLoaded: 0,
uploadTotal: selectedFile.size,
importPhase: null,
importPhaseCurrent: 0,
importPhaseTotal: 0,
importItem: null,
importProgress: null,
importedImages: [],
importedImageCount: 0,
statusMessage: null,
importStats: null,
uploadError: null,
@@ -278,12 +287,31 @@ export default class ImportExportSettingsPanel extends Component {
});
},
onProgress: (progress) => {
this.setState({
uploadStage: 'server',
importPhase: progress?.phase ?? null,
importPhaseCurrent: progress?.current ?? 0,
importPhaseTotal: progress?.total ?? 0,
importItem: progress?.item ?? null,
if (!progress || typeof progress !== 'object') return;
// Counter-based protocol: { categoriestotal, categoriesdone, ... }
this.setState({ uploadStage: 'server', importProgress: progress });
},
onImage: (images) => {
const list = Array.isArray(images) ? images.filter(Boolean) : [];
if (!list.length) return;
this.setState((prev) => {
let seq = this.imageSeq ?? 0;
const batch = list.map((item) => {
seq += 1;
return {
id: seq,
hash: typeof item === 'string' ? item : (item?.skelehash ?? null),
};
});
this.imageSeq = seq;
const next = [...batch.reverse(), ...prev.importedImages];
if (next.length > MAX_STREAMED_IMAGES) next.length = MAX_STREAMED_IMAGES;
return {
uploadStage: 'server',
importedImages: next,
importedImageCount: prev.importedImageCount + list.length,
statusMessage: null,
};
});
},
onDone: (stats) => {
@@ -293,7 +321,7 @@ export default class ImportExportSettingsPanel extends Component {
importStats: stats,
uploadSuccess: 'Import erfolgreich abgeschlossen.',
statusMessage: null,
importItem: null,
importProgress: null,
selectedFile: null,
});
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
@@ -354,20 +382,30 @@ export default class ImportExportSettingsPanel extends Component {
uploadProgress,
uploadLoaded,
uploadTotal,
importPhase,
importPhaseCurrent,
importPhaseTotal,
importItem,
importProgress,
importedImages,
importedImageCount,
statusMessage,
importStats,
uploadError,
uploadSuccess,
} = this.state;
const progressPercent =
importPhaseTotal > 0
? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100))
: null;
const phaseRows = PROGRESS_PHASES.map((phase) => {
const total = importProgress?.[phase.totalKey] ?? 0;
const done = importProgress?.[phase.doneKey] ?? 0;
return {
...phase,
total,
done,
percent: total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0,
};
}).filter((row) => row.total > 0);
const totalUnits = phaseRows.reduce((sum, row) => sum + row.total, 0);
const doneUnits = phaseRows.reduce((sum, row) => sum + Math.min(row.done, row.total), 0);
const overallPercent =
totalUnits > 0 ? Math.min(100, Math.round((doneUnits / totalUnits) * 100)) : null;
return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
@@ -750,7 +788,8 @@ export default class ImportExportSettingsPanel extends Component {
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" fontWeight={600} color="primary.main">
{(importPhase && PHASE_LABELS[importPhase]) || statusMessage || 'Verarbeitung läuft…'}
{statusMessage || 'Daten werden verarbeitet…'}
{overallPercent != null ? ` · ${overallPercent}%` : ''}
</Typography>
<Button
size="small"
@@ -761,32 +800,26 @@ export default class ImportExportSettingsPanel extends Component {
Abbrechen
</Button>
</Box>
{importPhaseTotal > 0 ? (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'monospace',
}}
>
{importItem || 'Fortschritt'}
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{importPhaseCurrent} / {importPhaseTotal} ({progressPercent}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progressPercent ?? 0}
sx={{ height: 8, borderRadius: 1 }}
/>
</>
{phaseRows.length > 0 ? (
<Stack spacing={1}>
{phaseRows.map((row) => (
<Box key={row.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{row.label}
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{row.done} / {row.total} ({row.percent}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={row.percent}
sx={{ height: 6, borderRadius: 1 }}
/>
</Box>
))}
</Stack>
) : (
<>
{statusMessage && (
@@ -797,6 +830,91 @@ export default class ImportExportSettingsPanel extends Component {
<LinearProgress sx={{ height: 8, borderRadius: 1 }} />
</>
)}
{/* Live stream of decoded SkeleHash placeholders */}
{importedImages.length > 0 && (
<Box sx={{ mt: 2, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography variant="caption" fontWeight={600} color="text.secondary">
SkeleHash-Platzhalter (Vorschau)
</Typography>
<Typography variant="caption" color="text.secondary">
{importedImageCount} Bild(er) verarbeitet
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: 1,
maxHeight: 220,
overflowY: 'auto',
}}
>
{importedImages.map((image) => (
<Box
key={image.id}
title={image.hash || 'kein Hash'}
sx={{
position: 'relative',
aspectRatio: '1 / 1',
borderRadius: 1,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
{image.hash ? (
<SkeleHashImage hash={image.hash} />
) : (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.disabled',
fontSize: '0.6rem',
textAlign: 'center',
p: 0.5,
}}
>
Kein Hash
</Box>
)}
<Box
sx={{
position: 'absolute',
inset: 'auto 0 0 0',
bgcolor: 'rgba(0,0,0,0.55)',
color: '#fff',
px: 0.5,
py: 0.25,
}}
>
<Typography
variant="caption"
noWrap
display="block"
sx={{ fontSize: '0.6rem', lineHeight: 1.2, fontFamily: 'monospace' }}
>
{shortHash(image.hash)}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
)}
</>
)}
</Box>

View File

@@ -0,0 +1,94 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import { decodeBase62 } from 'skelehash-wasm';
// Module-level cache so re-renders / re-mounts don't re-run the WASM decoder
// for the same SkeleHash. Stores a Promise resolving to the decoded payload.
const decodeCache = new Map(); // base62 -> Promise<{ rgba, metadata } | null>
function decodeSkeleHash(base62) {
if (!base62) return Promise.resolve(null);
let cached = decodeCache.get(base62);
if (!cached) {
cached = decodeBase62(base62).catch(() => null);
decodeCache.set(base62, cached);
}
return cached;
}
/**
* Renders a SkeleHash (Base62) as an RGBA canvas placeholder.
* Mirrors the synchronous-read / fine-grained styling approach of ThumbHashImage.
*/
export default class SkeleHashImage extends PureComponent {
constructor(props) {
super(props);
this.state = { ready: false };
this.canvasRef = createRef();
this.requestId = 0;
}
componentDidMount() {
this.draw();
}
componentDidUpdate(prevProps) {
if (prevProps.hash !== this.props.hash) {
this.draw();
}
}
componentWillUnmount() {
// Invalidate any in-flight decode so it doesn't touch the unmounted canvas.
this.requestId += 1;
}
async draw() {
const { hash } = this.props;
const requestId = ++this.requestId;
this.setState({ ready: false });
const decoded = await decodeSkeleHash(hash);
// Stale request (hash changed or component unmounted) — bail out.
if (requestId !== this.requestId) return;
const canvas = this.canvasRef.current;
if (!canvas || !decoded?.rgba || !decoded?.metadata) return;
const { rgba, metadata } = decoded;
const width = metadata.outputWidth;
const height = metadata.outputHeight;
if (!width || !height) return;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const clamped = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength);
ctx.putImageData(new ImageData(clamped, width, height), 0, 0);
this.setState({ ready: true });
}
render() {
const { sx, hash } = this.props;
const { ready } = this.state;
return (
<Box
component="canvas"
ref={this.canvasRef}
aria-hidden
data-skelehash={hash || undefined}
sx={{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: ready ? 1 : 0,
transition: 'opacity 200ms ease',
...sx,
}}
/>
);
}
}