diff --git a/src/api/index.js b/src/api/index.js index 12a1f54..b3cd403 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -139,6 +139,50 @@ async function httpBinary(path, { method = 'POST', body, contentType } = {}) { 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 = { search({ target, filter, offset, maxCount } = {}) { const params = new URLSearchParams({ target }); @@ -274,6 +318,20 @@ export const article = { body: { categoryIds }, }); }, + + /** + * GET /api/article/export.zip → application/zip (binary download) + * `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) + + * one `.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) { 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 `.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'); + }, }; /** diff --git a/src/components/ElevatedPill.jsx b/src/components/ElevatedPill.jsx index 1dbd6ee..f5c0314 100644 --- a/src/components/ElevatedPill.jsx +++ b/src/components/ElevatedPill.jsx @@ -11,10 +11,11 @@ class ElevatedPill extends Component { } render() { - const { label, active = false } = this.props; + const { label, active = false, onClick } = this.props; return ( ({ display: 'inline-flex', alignItems: 'center', diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index 7c51e0b..921d715 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -1,17 +1,37 @@ +import { useState } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; 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 PageLayout from './PageLayout'; import { NAV_ITEMS, SUB_NAV } from '../nav'; import { isKategorienPath } from '../pages/artikel/categoryRoute'; import { selectRenameMode, setRenameMode } from '../store/categorySlice'; 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 }) { const { pathname } = useLocation(); const dispatch = useDispatch(); const renameMode = useSelector(selectRenameMode); + 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) => { @@ -36,6 +56,16 @@ export default function SectionLayout({ sectionPath }) { 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 ? ( dispatch(setRenameMode(checked))} /> - } /> + } + onClick={(event) => setExtrasAnchor(event.currentTarget)} + /> + setExtrasAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + + + + + Artikel exportieren + + + + + + Kategorien exportieren + + + ) : null; diff --git a/src/utils/csv.js b/src/utils/csv.js new file mode 100644 index 0000000..1b58895 --- /dev/null +++ b/src/utils/csv.js @@ -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); +}