export
This commit is contained in:
@@ -139,6 +139,50 @@ async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */
|
||||||
|
async function httpGetBinary(path) {
|
||||||
|
logApi('request', {
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
url: new URL(path, window.location.origin).href,
|
||||||
|
origin: window.location.origin,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(path, { method: 'GET', credentials: 'include' });
|
||||||
|
|
||||||
|
logApi('response', {
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
ok: res.ok,
|
||||||
|
contentType: res.headers.get('content-type'),
|
||||||
|
responseHeaders: readableHeaders(res.headers),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
message = data.error || data.message || message;
|
||||||
|
} catch {
|
||||||
|
message = (await res.text().catch(() => message)) || message;
|
||||||
|
}
|
||||||
|
logApi('error-body', { method: 'GET', path, status: res.status, message, data });
|
||||||
|
const err = new Error(message || `Request failed (${res.status})`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.path = path;
|
||||||
|
err.method = 'GET';
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
export const article = {
|
export const article = {
|
||||||
search({ target, filter, offset, maxCount } = {}) {
|
search({ target, filter, offset, maxCount } = {}) {
|
||||||
const params = new URLSearchParams({ target });
|
const params = new URLSearchParams({ target });
|
||||||
@@ -274,6 +318,20 @@ export const article = {
|
|||||||
body: { categoryIds },
|
body: { categoryIds },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/article/export.zip → application/zip (binary download)
|
||||||
|
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
|
||||||
|
* one `<fullHash>.avif` per article image (content-addressed, deduplicated).
|
||||||
|
* Optional `categoryId` restricts the export to that category and below.
|
||||||
|
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||||
|
*/
|
||||||
|
exportZip(categoryId) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (categoryId != null) params.set('categoryId', String(categoryId));
|
||||||
|
const qs = params.toString();
|
||||||
|
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -419,6 +477,17 @@ export const category = {
|
|||||||
deleteImage(categoryId) {
|
deleteImage(categoryId) {
|
||||||
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
|
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/category/export.zip → application/zip (binary download)
|
||||||
|
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
|
||||||
|
* depth-first, `Ebene N` name column per depth) + one `<fullHash>.avif`
|
||||||
|
* entry per category image (content-addressed, deduplicated).
|
||||||
|
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||||
|
*/
|
||||||
|
exportZip() {
|
||||||
|
return httpGetBinary('/api/category/export.zip');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ class ElevatedPill extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { label, active = false } = this.props;
|
const { label, active = false, onClick } = this.props;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="label"
|
component="label"
|
||||||
|
onClick={onClick}
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import MenuList from '@mui/material/MenuList';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import GetAppIcon from '@mui/icons-material/GetApp';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import PageLayout from './PageLayout';
|
import PageLayout from './PageLayout';
|
||||||
import { NAV_ITEMS, SUB_NAV } from '../nav';
|
import { NAV_ITEMS, SUB_NAV } from '../nav';
|
||||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||||
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
|
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
|
||||||
import { ActionElevatedPill, ToggleElevatedPill } from './ElevatedPill';
|
import { ActionElevatedPill, ToggleElevatedPill } from './ElevatedPill';
|
||||||
|
import { article, category } from '../api';
|
||||||
|
|
||||||
|
function triggerDownload(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SectionLayout({ sectionPath }) {
|
export default function SectionLayout({ sectionPath }) {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const renameMode = useSelector(selectRenameMode);
|
const renameMode = useSelector(selectRenameMode);
|
||||||
|
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
||||||
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
||||||
const subNav = SUB_NAV[sectionPath];
|
const subNav = SUB_NAV[sectionPath];
|
||||||
const activeSub = subNav?.find((item) => {
|
const activeSub = subNav?.find((item) => {
|
||||||
@@ -36,6 +56,16 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
|
|
||||||
const showsRenameSwitch = activeSub?.segment === 'kategorien';
|
const showsRenameSwitch = activeSub?.segment === 'kategorien';
|
||||||
|
|
||||||
|
const exportKategorien = async () => {
|
||||||
|
setExtrasAnchor(null);
|
||||||
|
triggerDownload(await category.exportZip(), 'kategorien.zip');
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportArtikel = async () => {
|
||||||
|
setExtrasAnchor(null);
|
||||||
|
triggerDownload(await article.exportZip(), 'artikel.zip');
|
||||||
|
};
|
||||||
|
|
||||||
const titleAction = showsRenameSwitch ? (
|
const titleAction = showsRenameSwitch ? (
|
||||||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||||
<ToggleElevatedPill
|
<ToggleElevatedPill
|
||||||
@@ -43,7 +73,33 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
checked={renameMode}
|
checked={renameMode}
|
||||||
onToggle={(checked) => dispatch(setRenameMode(checked))}
|
onToggle={(checked) => dispatch(setRenameMode(checked))}
|
||||||
/>
|
/>
|
||||||
<ActionElevatedPill label="Extras" icon={<SettingsIcon sx={{ fontSize: 18 }} />} />
|
<ActionElevatedPill
|
||||||
|
label="Extras"
|
||||||
|
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
||||||
|
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
open={Boolean(extrasAnchor)}
|
||||||
|
anchorEl={extrasAnchor}
|
||||||
|
onClose={() => setExtrasAnchor(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<MenuList sx={{ py: 0.5 }}>
|
||||||
|
<MenuItem onClick={exportArtikel}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<GetAppIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Artikel exportieren</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={exportKategorien}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<GetAppIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Kategorien exportieren</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Popover>
|
||||||
</Box>
|
</Box>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
|||||||
41
src/utils/csv.js
Normal file
41
src/utils/csv.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/** Escape a value for a CSV field ("..." double-quoting per RFC 4180). */
|
||||||
|
function csvField(value) {
|
||||||
|
const text = value == null ? '' : String(value);
|
||||||
|
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CSV string from an array of row objects (keys become the header).
|
||||||
|
* Uses `;` as the field separator with a `sep=;` hint line so Excel opens it
|
||||||
|
* correctly regardless of the OS list-separator locale.
|
||||||
|
*/
|
||||||
|
export function toCsv(rows) {
|
||||||
|
if (!rows.length) return '';
|
||||||
|
const headers = Object.keys(rows[0]);
|
||||||
|
const lines = [
|
||||||
|
'sep=;',
|
||||||
|
headers.map(csvField).join(';'),
|
||||||
|
...rows.map((row) => headers.map((h) => csvField(row[h])).join(';')),
|
||||||
|
];
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a browser download of the given string, encoded as Windows-1252. */
|
||||||
|
export function downloadCsv(filename, csv) {
|
||||||
|
const bytes = new Uint8Array(csv.length);
|
||||||
|
for (let i = 0; i < csv.length; i++) {
|
||||||
|
const code = csv.charCodeAt(i);
|
||||||
|
// Windows-1252 covers the single-byte Latin-1 range + punctuation (e.g. „ " –).
|
||||||
|
// Category names are German/single-byte, so chars are well inside this range.
|
||||||
|
bytes[i] = code <= 0xff ? code : 0x3f; // '?' for any out-of-range char
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: 'text/csv;charset=windows-1252;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user