diff --git a/NGINX.md b/NGINX.md new file mode 100644 index 0000000..e4b26b5 --- /dev/null +++ b/NGINX.md @@ -0,0 +1,466 @@ +# NGINX Configuration Guide for Dashfront & Dashback + +This document provides a production-tested, complete NGINX reverse-proxy and static hosting guide for **Dashfront** (Vite + React 19 PWA) and **Dashback** (Express REST + SSE API). + +--- + +## Table of Contents + +1. [Architecture & Request Flow](#1-architecture--request-flow) +2. [Key Technical Requirements](#2-key-technical-requirements) +3. [Production Configuration (`dashfront.conf`)](#3-production-configuration-dashfrontconf) +4. [Detailed Breakdown of Critical Directives](#4-detailed-breakdown-of-critical-directives) + - [PWA & Cache Invalidation Strategy](#pwa--cache-invalidation-strategy) + - [SPA Routing (`try_files`)](#spa-routing-try_files) + - [Server-Sent Events (SSE) Buffering & Timeouts](#server-sent-events-sse-buffering--timeouts) + - [File Uploads & Request Body Limits](#file-uploads--request-body-limits) + - [WebAssembly (WASM) MIME Types](#webassembly-wasm-mime-types) + - [Camera & Hardware Permissions](#camera--hardware-permissions) +5. [Alternative: Remote Vite Dev Server Proxy](#5-alternative-remote-vite-dev-server-proxy) +6. [Deployment & Operations Runbook](#6-deployment--operations-runbook) +7. [Troubleshooting Matrix](#7-troubleshooting-matrix) + +--- + +## 1. Architecture & Request Flow + +In production, NGINX acts as both the high-performance static web server for Dashfront assets and the secure reverse proxy for the Dashback Express service. + +``` + ┌──────────────────────────────────────────────┐ + │ CLIENT │ + │ (Desktop Browser, Tablet, Mobile PWA) │ + └──────────────────────┬───────────────────────┘ + │ HTTPS (:443) + ▼ + ┌──────────────────────────────────────────────┐ + │ NGINX │ + │ TLS Termination, Gzip, Caching, Auth │ + └──────────────┬────────────────┬──────────────┘ + │ │ + /assets/*, /index.html │ │ /api/* + /sw.js, static files │ │ (REST & SSE) + ▼ ▼ + ┌───────────────────┐ ┌───────────────────┐ + │ Static Build Dir │ │ Dashback │ + │ `dashfront/dist` │ │ Express API │ + │ │ │ (localhost:4208) │ + └───────────────────┘ └───────────────────┘ +``` + +| Path | Handled By | Cache Policy | Notes | +|---|---|---|---| +| `/index.html` & `/` | NGINX static file | `no-cache, no-store, must-revalidate` | Always fresh so users get new script hashes | +| `/sw.js`, `/registerSW.js` | NGINX static file | `no-cache, no-store, must-revalidate` | **Must never cache**; avoids bricking PWA updates | +| `/workbox-*.js` | NGINX static file | `no-cache, no-store, must-revalidate` | Workbox runtime loader | +| `/manifest.webmanifest` | NGINX static file | `public, max-age=3600` | PWA manifest definition | +| `/assets/*` | NGINX static file | `public, max-age=31536000, immutable` | Content-hashed JS, CSS, WASM, and workers | +| `/api/events` | Dashback (`:4208`) | `no-cache, no-buffering` | Server-Sent Events (SSE) live push notifications | +| `/api/*` | Dashback (`:4208`) | Dynamic / Backend controlled | REST endpoints, auth cookies, image & PDF uploads | +| All other paths | NGINX `try_files` | Fallback to `/index.html` | React Router HTML5 History API deep linking | + +--- + +## 2. Key Technical Requirements + +1. **PWA Lifecycles**: A Progressive Web App checks for updates by fetching `sw.js`. If NGINX caches `sw.js` or `index.html`, client devices can remain stuck on obsolete bundles indefinitely. +2. **SSE Streaming**: Real-time push updates at `/api/events` require NGINX proxy buffering and proxy caching to be turned **OFF**, with a 24-hour read timeout to prevent arbitrary 60-second connection drops. +3. **Payload Limits**: Inbound delivery invoices (PDF receipts scanned client-side) and high-resolution article photos (AVIF) require a request body limit of at least `50m`–`100m` (matching Dashback's `BODY_LIMIT`). +4. **Cookie Authentication**: Dashback issues HTTP-only `token` session cookies. NGINX must pass the original `Host`, `X-Real-IP`, and `X-Forwarded-Proto https` headers so Express sets and retains cookies correctly. +5. **Camera Access (HTTPS)**: Mobile barcode scanning requires a secure origin (HTTPS) and appropriate `Permissions-Policy` response headers. + +--- + +## 3. Production Configuration (`dashfront.conf`) + +Place this configuration file at `/etc/nginx/sites-available/dashfront` (or adapt it to your existing reverse proxy structure): + +```nginx +# ============================================================================== +# Dashfront + Dashback Production NGINX Server Configuration +# ============================================================================== + +# Map Connection header for WebSocket upgrades (if proxying dev server or WebSockets) +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ------------------------------------------------------------------------------ +# 1. HTTP -> HTTPS Redirect +# ------------------------------------------------------------------------------ +server { + listen 80; + listen [::]:80; + server_name dash.example.com; # <-- Replace with your domain or server IP + + # Let's Encrypt / ACME challenge directory + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ------------------------------------------------------------------------------ +# 2. Main HTTPS Server Block +# ------------------------------------------------------------------------------ +server { + # NGINX >= 1.25.1 uses 'http2 on;'. For older NGINX, use: listen 443 ssl http2; + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + server_name dash.example.com; # <-- Replace with your domain or server IP + + # --- SSL / TLS Certificates --- + ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # --- Static File Root --- + # Point directly to the dist/ directory created by `npm run build` + root /home/seb/src/dashfront/dist; + index index.html; + + # --- Request Body & Uploads --- + # Supports large delivery PDF uploads and client-encoded AVIF photos + client_max_body_size 100m; + client_body_timeout 300s; + + # --- Compression (Gzip) --- + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/x-javascript + application/json + application/xml + application/manifest+json + application/wasm + image/svg+xml; + + # --- Security Headers --- + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Allow camera access for the built-in barcode scanner + add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always; + + # ========================================================================== + # API & Backend Routing (Dashback on port 4208) + # ========================================================================== + + # Dedicated SSE route: Real-time broadcast events + location = /api/events { + proxy_pass http://127.0.0.1:4208; + proxy_http_version 1.1; + + # Clear Connection header to maintain persistent HTTP/1.1 stream + proxy_set_header Connection ""; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Disable buffering and caching completely for immediate push + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + + # Prevent premature disconnections; keep SSE open for 24 hours + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # General API endpoints (REST) + location /api/ { + proxy_pass http://127.0.0.1:4208; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Retain cookies across domain/path + proxy_cookie_path / /; + + # Streaming uploads (PDF receipts, AVIF photos) + proxy_request_buffering off; + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # ========================================================================== + # PWA & Service Worker Rules (Zero Caching) + # ========================================================================== + + # Service worker scripts MUST NEVER be cached by HTTP cache + location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + access_log off; + } + + # PWA web manifest and browser icons + location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ { + add_header Cache-Control "public, max-age=3600"; + add_header Vary "Accept-Encoding"; + access_log off; + } + + # ========================================================================== + # Static Assets & Hashed Bundles (Aggressive Immutable Caching) + # ========================================================================== + + # Vite content-hashed assets: JS, CSS, WASM, Web Workers + # (Example: /assets/index-CutpK3vd.js, /assets/avif_enc-Co4TcJko.wasm) + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Vary "Accept-Encoding"; + access_log off; + } + + # Ensure WebAssembly MIME type is correctly set + location ~* \.wasm$ { + default_type application/wasm; + add_header Cache-Control "public, max-age=31536000, immutable"; + access_log off; + } + + # ========================================================================== + # HTML5 SPA Entry Point & Navigation Fallback + # ========================================================================== + + # index.html must never be cached so new release deployments take effect immediately + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # SPA routing: Serve existing static file or directory; otherwise fallback to /index.html + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +--- + +## 4. Detailed Breakdown of Critical Directives + +### PWA & Cache Invalidation Strategy + +PWA update lifecycles depend on byte-for-byte comparisons of `sw.js` when the user visits the page: +1. **`sw.js` and `registerSW.js`**: + ```nginx + add_header Cache-Control "no-cache, no-store, must-revalidate"; + ``` + If NGINX serves `sw.js` with a 30-day cache header, browser service worker checks will read from disk cache and never detect new application releases. +2. **`index.html`**: + Must also have `no-cache, no-store, must-revalidate`. When a new build is run, Vite produces chunks with new hashes (e.g., `index-AbCd12.js`). If `index.html` is cached, users continue loading old references that may no longer exist on disk. +3. **`/assets/`**: + Because Vite / Rolldown generates content-addressed filenames (hashes change whenever contents change), these files are completely immutable and safe to cache for 1 year (`max-age=31536000, immutable`). + +### SPA Routing (`try_files`) + +Dashfront uses React Router with HTML5 History API pushState: +- Direct visits to URLs like `/artikel/10001`, `/kategorien/12`, `/lieferung/neu`, or `/kunde` do not correspond to files on disk. +- The directive: + ```nginx + try_files $uri $uri/ /index.html; + ``` + instructs NGINX to first check if a matching static file exists (e.g. `/favicon.svg` or `/assets/...`). If not found, it internally rewrites the request to `/index.html` without returning an HTTP 404, allowing React Router to resolve the route in-browser. + +### Server-Sent Events (SSE) Buffering & Timeouts + +The backend provides real-time model invalidation via `GET /api/events`. By default, NGINX buffers HTTP responses. If buffering is enabled: +1. Events sent by Dashback stay in NGINX's internal memory buffer until the buffer fills up (often several kilobytes), causing clients to miss instant UI updates. +2. NGINX will close idle proxy connections after `proxy_read_timeout` (default 60s). + +The dedicated `/api/events` block fixes both issues: +```nginx +proxy_buffering off; +proxy_cache off; +chunked_transfer_encoding off; +proxy_set_header Connection ""; +proxy_read_timeout 86400s; +``` + +### File Uploads & Request Body Limits + +In Dashfront: +- Inbound deliveries support multi-page PDF invoices and OCR processing (`pdfconvert-wasm`). +- Article management supports client-side AVIF image generation. +- The default NGINX limit is `1m` (`client_max_body_size 1m;`), which causes HTTP 413 (Payload Too Large) errors on invoice uploads. +- Setting: + ```nginx + client_max_body_size 100m; + proxy_request_buffering off; + ``` + allows uploads up to 100 megabytes and streams the body directly to Express without waiting for NGINX to buffer the entire file on disk. + +### WebAssembly (WASM) MIME Types + +Dashfront uses WebAssembly for: +- `@jsquash/avif` (image encoding) +- `@jsquash/resize` (squoosh resizing) +- `pdfconvert-wasm` (PDF page extraction and rasterization) + +Modern browsers require the HTTP header `Content-Type: application/wasm` for streaming compilation (`WebAssembly.instantiateStreaming`). If missing, NGINX may default to `application/octet-stream`. The WASM block guarantees the correct MIME header: +```nginx +location ~* \.wasm$ { + default_type application/wasm; +} +``` + +### Camera & Hardware Permissions + +The article lookup and delivery receipt features allow scanning barcodes using device cameras. Web browsers enforce two strict rules for camera access: +1. **Secure Origin**: The page must be served over HTTPS or `localhost`. Plain HTTP over local IP addresses (e.g., `http://192.168.1.100/`) will automatically disable `navigator.mediaDevices`. +2. **Permissions Policy**: The header `Permissions-Policy "camera=(self)"` ensures the browser allows Dashfront to access connected webcams or smartphone cameras. + +--- + +## 5. Alternative: Remote Vite Dev Server Proxy + +If you want to access the live **Vite development server** (running on `localhost:4209` with HMR) through NGINX from a smartphone, tablet, or external domain: + +```nginx +# Map connection upgrade for Vite HMR WebSockets +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + server_name dev-dash.example.com; + + ssl_certificate /etc/letsencrypt/live/dev-dash.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dev-dash.example.com/privkey.pem; + + client_max_body_size 100m; + + # 1. API Calls -> Dashback (Port 4208) + location /api/ { + proxy_pass http://127.0.0.1:4208; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 86400s; + } + + # 2. Vite Dev Server & HMR -> Vite (Port 4209) + location / { + proxy_pass http://127.0.0.1:4209; + proxy_http_version 1.1; + + # Crucial for Vite Hot Module Replacement (HMR) WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + } +} +``` + +> [!NOTE] +> When proxying to Vite dev server, ensure `server.allowedHosts = true` or add your domain to `allowedHosts` in [vite.config.js](file:///home/seb/src/dashfront/vite.config.js). + +--- + +## 6. Deployment & Operations Runbook + +### Step 1: Build Dashfront for Production + +On the development machine or build server: + +```bash +cd /home/seb/src/dashfront +npm run build +``` + +This compiles optimized bundles and service worker files into `dashfront/dist`. + +### Step 2: Install and Verify NGINX Configuration + +```bash +# 1. Copy config file to sites-available +sudo cp deploy/nginx-dashfront.conf /etc/nginx/sites-available/dashfront + +# 2. Enable site by creating a symlink +sudo ln -s /etc/nginx/sites-available/dashfront /etc/nginx/sites-enabled/ + +# 3. Test configuration syntax +sudo nginx -t + +# 4. Reload NGINX without dropping active connections +sudo systemctl reload nginx +``` + +### Step 3: Obtain TLS Certificates with Certbot + +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d dash.example.com +``` + +### Step 4: Ensure Filesystem Permissions + +NGINX worker processes run as the `www-data` user by default. Ensure the directory path to `dist/` is readable: + +```bash +# Allow www-data execution on parent path components +chmod 755 /home/seb /home/seb/src /home/seb/src/dashfront /home/seb/src/dashfront/dist +``` + +Or deploy build artifacts into a standard web directory: + +```bash +sudo mkdir -p /var/www/dashfront +sudo rsync -av --delete /home/seb/src/dashfront/dist/ /var/www/dashfront/dist/ +sudo chown -R www-data:www-data /var/www/dashfront +``` + +--- + +## 7. Troubleshooting Matrix + +| Symptom | Cause | Solution | +|---|---|---| +| **HTTP 404 on page reload** on sub-routes (`/artikel`, `/kunde`) | Missing SPA fallback in NGINX | Ensure `try_files $uri $uri/ /index.html;` is present in `location /`. | +| **PWA does not update** after deployment | `sw.js` or `index.html` cached in browser | Verify `Cache-Control: no-cache, no-store, must-revalidate` on `sw.js`, `registerSW.js`, and `index.html`. | +| **HTTP 413 (Payload Too Large)** when uploading invoice PDF or image | Default NGINX upload limit is 1MB | Add `client_max_body_size 100m;` to the `server` or `location /api/` block. | +| **Live SSE events disconnect every 60s** | Default `proxy_read_timeout` timeout | Set `proxy_read_timeout 86400s;` and `proxy_buffering off;` on `/api/events`. | +| **Login loop / Cookie not sent** | Secure cookie rejected on plain HTTP, or missing proxy headers | Access via HTTPS; ensure `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` are set. | +| **Barcode camera scanner disabled** | Page accessed over insecure HTTP or missing permissions policy | Use HTTPS; check that `Permissions-Policy "camera=(self)"` header is present. | +| **WASM compilation error** (`incorrect response MIME type`) | NGINX serving `.wasm` as `application/octet-stream` | Add `default_type application/wasm;` for `\.wasm$` extensions. | +| **Vite HMR disconnects immediately** (Dev proxy) | Missing WebSocket upgrade headers | Ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection $connection_upgrade;` are configured. | diff --git a/README.md b/README.md index a2380ec..df58f88 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at To test the full PWA precache (production build + preview, no HMR), use `npm run dev:pwa` instead. +For production deployment with NGINX, see [NGINX.md](file:///home/seb/src/dashfront/NGINX.md). + Dashback must be running and your Google account must be authorized on the backend. diff --git a/deploy/nginx-dashfront.conf b/deploy/nginx-dashfront.conf new file mode 100644 index 0000000..1ebda20 --- /dev/null +++ b/deploy/nginx-dashfront.conf @@ -0,0 +1,168 @@ +# ============================================================================== +# Dashfront + Dashback Production NGINX Server Configuration +# ============================================================================== + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ------------------------------------------------------------------------------ +# 1. HTTP -> HTTPS Redirect +# ------------------------------------------------------------------------------ +server { + listen 80; + listen [::]:80; + server_name dash.example.com; # <-- Replace with your domain or server IP + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ------------------------------------------------------------------------------ +# 2. Main HTTPS Server Block +# ------------------------------------------------------------------------------ +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + server_name dash.example.com; # <-- Replace with your domain or server IP + + # --- SSL / TLS Certificates --- + ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # --- Static File Root --- + root /home/seb/src/dashfront/dist; + index index.html; + + # --- Request Body & Uploads (PDF receipts, AVIF photos) --- + client_max_body_size 100m; + client_body_timeout 300s; + + # --- Compression (Gzip) --- + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/x-javascript + application/json + application/xml + application/manifest+json + application/wasm + image/svg+xml; + + # --- Security Headers --- + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always; + + # ========================================================================== + # API & Backend Routing (Dashback on port 4208) + # ========================================================================== + + # Dedicated SSE route: Real-time broadcast events + location = /api/events { + proxy_pass http://127.0.0.1:4208; + proxy_http_version 1.1; + + proxy_set_header Connection ""; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # General API endpoints (REST) + location /api/ { + proxy_pass http://127.0.0.1:4208; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cookie_path / /; + + proxy_request_buffering off; + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # ========================================================================== + # PWA & Service Worker Rules (Zero Caching) + # ========================================================================== + + location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + access_log off; + } + + location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ { + add_header Cache-Control "public, max-age=3600"; + add_header Vary "Accept-Encoding"; + access_log off; + } + + # ========================================================================== + # Static Assets & Hashed Bundles (Aggressive Immutable Caching) + # ========================================================================== + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Vary "Accept-Encoding"; + access_log off; + } + + location ~* \.wasm$ { + default_type application/wasm; + add_header Cache-Control "public, max-age=31536000, immutable"; + access_log off; + } + + # ========================================================================== + # HTML5 SPA Entry Point & Navigation Fallback + # ========================================================================== + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json index 83c8428..0409aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,16 +14,14 @@ "@jsquash/resize": "^2.1.1", "@mui/icons-material": "^9.4.0", "@mui/material": "^9.4.0", - "@reduxjs/toolkit": "^2.12.0", "@shopify/draggable": "^1.2.1", "dashgui": "file:../dashgui", "eslint": "^10.11.0", "notistack": "^3.0.2", "pdfconvert-wasm": "^0.3.0", "react": "^19.3.0", - "react-automat": "^0.1.0", + "react-automat": "file:../automat", "react-dom": "^19.3.0", - "react-redux": "^9.3.0", "react-router-dom": "^7.18.4", "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" @@ -35,6 +33,25 @@ "vite-plugin-pwa": "^1.3.0" } }, + "../automat": { + "name": "react-automat", + "version": "0.1.0", + "license": "0BSD", + "devDependencies": { + "@vitejs/plugin-react": "^6.1.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.0" + }, + "peerDependencies": { + "react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "../dashgui": { "version": "0.1.0", "devDependencies": { @@ -57,23 +74,6 @@ "react-dom": "^19.0.0" } }, - "node_modules/@apideck/better-ajv-errors": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", - "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsonpointer": "^5.0.1", - "leven": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "ajv": ">=8" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2317,32 +2317,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", @@ -2744,9 +2718,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", - "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", "cpu": [ "arm" ], @@ -2758,9 +2732,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", - "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", "cpu": [ "arm64" ], @@ -2772,9 +2746,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", - "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", "cpu": [ "arm64" ], @@ -2786,9 +2760,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", - "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", "cpu": [ "x64" ], @@ -2800,9 +2774,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", - "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", "cpu": [ "arm64" ], @@ -2814,9 +2788,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", - "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", "cpu": [ "x64" ], @@ -2828,9 +2802,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", - "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", "cpu": [ "arm" ], @@ -2845,9 +2819,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", - "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", "cpu": [ "arm" ], @@ -2862,9 +2836,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", - "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", "cpu": [ "arm64" ], @@ -2879,9 +2853,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", - "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", "cpu": [ "arm64" ], @@ -2896,9 +2870,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", - "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", "cpu": [ "loong64" ], @@ -2913,9 +2887,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", - "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", "cpu": [ "loong64" ], @@ -2930,9 +2904,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", - "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", "cpu": [ "ppc64" ], @@ -2947,9 +2921,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", - "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", "cpu": [ "ppc64" ], @@ -2964,9 +2938,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", - "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", "cpu": [ "riscv64" ], @@ -2981,9 +2955,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", - "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", "cpu": [ "riscv64" ], @@ -2998,9 +2972,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", - "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", "cpu": [ "s390x" ], @@ -3015,9 +2989,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", - "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", "cpu": [ "x64" ], @@ -3032,9 +3006,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", - "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", "cpu": [ "x64" ], @@ -3049,9 +3023,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", - "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", "cpu": [ "x64" ], @@ -3063,9 +3037,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", - "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", "cpu": [ "arm64" ], @@ -3077,9 +3051,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", - "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", "cpu": [ "arm64" ], @@ -3091,9 +3065,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", - "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", "cpu": [ "ia32" ], @@ -3105,9 +3079,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", - "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", "cpu": [ "x64" ], @@ -3119,9 +3093,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", - "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", "cpu": [ "x64" ], @@ -3138,18 +3112,6 @@ "integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -3229,12 +3191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -3287,16 +3243,15 @@ } }, "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -3991,9 +3946,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.433", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", - "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "version": "1.5.434", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.434.tgz", + "integrity": "sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==", "dev": true, "license": "ISC" }, @@ -4280,89 +4235,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -4551,17 +4423,19 @@ "license": "MIT" }, "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", + "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/flat-cache": { @@ -4997,16 +4871,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5570,10 +5434,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5930,16 +5793,18 @@ "license": "MIT" }, "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash.debounce": { @@ -6178,32 +6043,33 @@ } }, "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { @@ -6481,18 +6347,8 @@ } }, "node_modules/react-automat": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/react-automat/-/react-automat-0.1.0.tgz", - "integrity": "sha512-/lGKX2gXyBo52rRc6qC0ZGeThuD2C/LNjJWCChA/WC3JCJ4anyx7xCREfNmMOgRUBE0k4+LF0EbvzJwb4/RxfQ==", - "license": "0BSD", - "peerDependencies": { - "react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } + "resolved": "../automat", + "link": true }, "node_modules/react-dom": { "version": "19.3.0", @@ -6512,29 +6368,6 @@ "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", "license": "MIT" }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -6589,21 +6422,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6733,12 +6551,6 @@ "dev": true, "license": "ISC" }, - "node_modules/reselect": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", - "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6804,9 +6616,9 @@ } }, "node_modules/rollup": { - "version": "4.63.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", - "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6821,31 +6633,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.3", - "@rollup/rollup-android-arm64": "4.63.3", - "@rollup/rollup-darwin-arm64": "4.63.3", - "@rollup/rollup-darwin-x64": "4.63.3", - "@rollup/rollup-freebsd-arm64": "4.63.3", - "@rollup/rollup-freebsd-x64": "4.63.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", - "@rollup/rollup-linux-arm-musleabihf": "4.63.3", - "@rollup/rollup-linux-arm64-gnu": "4.63.3", - "@rollup/rollup-linux-arm64-musl": "4.63.3", - "@rollup/rollup-linux-loong64-gnu": "4.63.3", - "@rollup/rollup-linux-loong64-musl": "4.63.3", - "@rollup/rollup-linux-ppc64-gnu": "4.63.3", - "@rollup/rollup-linux-ppc64-musl": "4.63.3", - "@rollup/rollup-linux-riscv64-gnu": "4.63.3", - "@rollup/rollup-linux-riscv64-musl": "4.63.3", - "@rollup/rollup-linux-s390x-gnu": "4.63.3", - "@rollup/rollup-linux-x64-gnu": "4.63.3", - "@rollup/rollup-linux-x64-musl": "4.63.3", - "@rollup/rollup-openbsd-x64": "4.63.3", - "@rollup/rollup-openharmony-arm64": "4.63.3", - "@rollup/rollup-win32-arm64-msvc": "4.63.3", - "@rollup/rollup-win32-ia32-msvc": "4.63.3", - "@rollup/rollup-win32-x64-gnu": "4.63.3", - "@rollup/rollup-win32-x64-msvc": "4.63.3", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" } }, @@ -7641,15 +7453,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", @@ -7955,6 +7758,47 @@ "node": ">=20.0.0" } }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -8181,6 +8025,62 @@ "node": ">=6" } }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ad25583..2f676e0 100644 --- a/package.json +++ b/package.json @@ -20,16 +20,14 @@ "@jsquash/resize": "^2.1.1", "@mui/icons-material": "^9.4.0", "@mui/material": "^9.4.0", - "@reduxjs/toolkit": "^2.12.0", "@shopify/draggable": "^1.2.1", "dashgui": "file:../dashgui", "eslint": "^10.11.0", "notistack": "^3.0.2", "pdfconvert-wasm": "^0.3.0", "react": "^19.3.0", - "react-automat": "^0.1.0", + "react-automat": "file:../automat", "react-dom": "^19.3.0", - "react-redux": "^9.3.0", "react-router-dom": "^7.18.4", "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index cfde2fe..7d77b06 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -1,8 +1,14 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { notifyError } from '../utils/notify'; -import { parseCategoryTreeResponse } from '../store/categorySlice'; +import { parseCategoryTreeResponse } from '../utils/categoryUtils'; import { ApiClient } from './ApiClient'; import artikelAutomat from '../automats/artikelAutomat'; +import companyAutomat from '../automats/companyAutomat'; +import brandAutomat from '../automats/brandAutomat'; +import unitAutomat from '../automats/unitAutomat'; +import authAutomat from '../automats/authAutomat'; +import categoryAutomat from '../automats/categoryAutomat'; + /** * Stale-while-revalidate cache: readers keep the current promise; update() @@ -172,20 +178,25 @@ export class StorageManager extends ApiClient { * @returns {{ categories: object, rootCategoryId: unknown } | null} */ getCategoryState() { - const treeData = this.categoryTree; - const companies = this.companies; - const session = this.authMe; - if (treeData == null || companies == null || session == null) return null; - - const companyId = session.user?.selectedCompanyId; - if (companyId == null) return null; - const company = companies.find((c) => c.id === companyId); + const company = this.company; if (company == null) return null; - const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId); + const treeData = this.categoryTree; + if (treeData != null) { + // Legacy: tree was loaded via the old cache mechanism + const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId); + return { + categories: parsed.categories, + rootCategoryId: parsed.rootCategoryId, + }; + } + + // New path: use categoryAutomat state directly + const catState = categoryAutomat.state; + if (catState.categories == null || catState.treeStatus !== 'succeeded') return null; return { - categories: parsed.categories, - rootCategoryId: parsed.rootCategoryId, + categories: catState.categories, + rootCategoryId: catState.rootCategoryId, }; } @@ -375,13 +386,6 @@ export class StorageManager extends ApiClient { #initCaches() { const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe()); - this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex()); - const articleIndexCache = this.#articleIndexCache; - const brandCache = createCache('GET /brand', () => this.#loadBrands()); - const unitCache = createCache('GET /unit', () => this.#loadUnits()); - const companyCache = createCache('GET /company', () => this.#loadCompanies()); - const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree()); - const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const customergroupCache = createCache('GET /customergroup', () => this.#loadCustomergroups(), @@ -391,6 +395,19 @@ export class StorageManager extends ApiClient { const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); const orderCache = createCache('GET /order', () => this.#loadOrders()); + // articleIndex is auth-gated — create a deferred cache that only loads after auth + this.#articleIndexCache = this.#createDeferredCache( + 'GET /articleIndex', + () => this.#loadArticleIndex(), + ); + const articleIndexCache = this.#articleIndexCache; + + // category tree: deferred, reloaded via categoryAutomat + const categoryTreeCache = this.#createDeferredCache( + 'GET /category/tree', + () => this.#loadCategoryTree(), + ); + Object.defineProperties(this, { authMePromise: { get: () => authMeCache.promise }, updateAuthMe: { value: authMeCache.update }, @@ -400,29 +417,68 @@ export class StorageManager extends ApiClient { articleIndexPromise: { get: () => articleIndexCache.promise }, updateArticleIndex: { value: articleIndexCache.update }, - brandPromise: { get: () => brandCache.promise }, - updateBrand: { value: brandCache.update }, - /** Settled GET /brand list, or undefined. */ - brands: { get: () => brandCache.value }, + // ── Automat bridges (brand, unit, company, posterminal) ────────────────── + /** Brand list from brandAutomat (may be empty until authed). */ + brands: { get: () => brandAutomat.state.brands }, + /** Promise that resolves when brands are first loaded. */ + brandPromise: { + get: () => brandAutomat.state.status === 'succeeded' + ? Promise.resolve(brandAutomat.state.brands) + : new Promise((resolve) => { + const unsub = brandAutomat.subscribe((s) => { + if (s.status === 'succeeded') { unsub(); resolve(s.brands); } + }); + }), + }, + /** Reload brands from API. */ + updateBrand: { value: () => brandAutomat.actions.load() }, - unitPromise: { get: () => unitCache.promise }, - updateUnit: { value: unitCache.update }, - /** Settled GET /unit list, or undefined. */ - units: { get: () => unitCache.value }, + /** Unit list from unitAutomat. */ + units: { get: () => unitAutomat.state.units }, + /** Promise that resolves when units are first loaded. */ + unitPromise: { + get: () => unitAutomat.state.status === 'succeeded' + ? Promise.resolve(unitAutomat.state.units) + : new Promise((resolve) => { + const unsub = unitAutomat.subscribe((s) => { + if (s.status === 'succeeded') { unsub(); resolve(s.units); } + }); + }), + }, + /** Reload units from API. */ + updateUnit: { value: () => unitAutomat.actions.load() }, - companyPromise: { get: () => companyCache.promise }, - updateCompany: { value: companyCache.update }, - /** Settled GET /company list, or undefined. */ - companies: { get: () => companyCache.value }, + /** + * Current company object from companyAutomat, or null. + * Previously: companies array from GET /api/company. + * Consumers that used companies.find(c => c.id === companyId) can use .company directly. + */ + company: { get: () => companyAutomat.state.current ?? null }, + /** Promise that resolves when company data is loaded. */ + companyPromise: { + get: () => companyAutomat.state.status === 'succeeded' + ? Promise.resolve(companyAutomat.state.current) + : new Promise((resolve, reject) => { + const unsub = companyAutomat.subscribe((s) => { + if (s.status === 'succeeded') { unsub(); resolve(s.current); } + else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); } + }); + }), + }, + /** Reload company data from API. */ + updateCompany: { value: () => companyAutomat.actions.load() }, + // Legacy compat: some components call store.companies.find(c => c.id === companyId). + // Return a single-element array so .find() still works. + companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] }, + + // ── Category tree bridge ───────────────────────────────────────────────── categoryTreePromise: { get: () => categoryTreeCache.promise }, updateCategoryTree: { value: categoryTreeCache.update }, /** Settled GET /category/tree body, or undefined. */ categoryTree: { get: () => categoryTreeCache.value }, - posterminalPromise: { get: () => posterminalCache.promise }, - updatePosterminal: { value: posterminalCache.update }, - + // ── Remaining non-auth-gated caches ───────────────────────────────────── deliveryPromise: { get: () => deliveryCache.promise }, updateDelivery: { value: deliveryCache.update }, /** Settled GET /delivery list, or undefined. */ @@ -456,8 +512,63 @@ export class StorageManager extends ApiClient { /** Close and reopen GET /api/events (required after selected-company change). */ reconnectEvents: { value: () => this.#reconnectEvents() }, }); + + // Trigger articleIndex load once auth is established + authAutomat.subscribe((auth) => { + if (auth.status === 'authed' && auth.user?.selectedCompanyId) { + if (!articleIndexCache.value) { + void articleIndexCache.update?.().catch(() => {}); + } + } + }); } + /** + * Create a cache that does NOT load immediately but waits for an explicit trigger. + * Differs from createCache in that the initial load promise is not started. + */ + #createDeferredCache(label, load) { + let current; + let resolveInitial; + let rejectInitial; + let promise = new Promise((res, rej) => { + resolveInitial = res; + rejectInitial = rej; + }); + + async function update() { + try { + const data = await load(); + current = data; + promise = Promise.resolve(data); + resolveInitial?.(data); + resolveInitial = null; + return data; + } catch (err) { + console.error(`[storageMgr] update ${label} failed`, err); + rejectInitial?.(err); + rejectInitial = null; + throw err; + } + } + + function replace(data) { + current = data; + promise = Promise.resolve(data); + resolveInitial?.(data); + resolveInitial = null; + return data; + } + + return { + update, + replace, + get promise() { return promise; }, + get value() { return current; }, + }; + } + + /** * GET /api/events — SSE signals for the selected company. * Stream is bound at connect time; reconnect after company switch. diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js new file mode 100644 index 0000000..a521635 --- /dev/null +++ b/src/automats/brandAutomat.js @@ -0,0 +1,81 @@ +import { Automat } from 'react-automat'; +import { brand as brandApi } from '../api'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} BrandState + * @property {Array} brands + * @property {'idle'|'loading'|'succeeded'|'failed'} status + * @property {string|null} error + */ + +/** @type {BrandState} */ +const initialState = { + brands: [], + status: 'idle', + error: null, +}; + +function sortBrands(brands) { + return [...brands].sort((a, b) => + String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'), + ); +} + +let brandAutomat; + +const actions = { + /** Load all brands for the selected company. */ + async load() { + brandAutomat.setState({ status: 'loading', error: null }); + try { + const res = await brandApi.list(); + const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []); + brandAutomat.setState({ brands, status: 'succeeded' }); + return brands; + } catch (err) { + brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } + }, + + /** + * Upsert a brand (create or update by name). + * @param {{ name: string, gsprpinfo?: string }} body + */ + async upsert(body) { + const res = await brandApi.upsert(body); + const row = res.brand; + const brands = sortBrands([ + ...brandAutomat.state.brands.filter((b) => b.id !== row.id), + row, + ]); + brandAutomat.setState({ brands }); + return row; + }, + + /** Reset to idle state (called on logout / company switch). */ + reset() { + brandAutomat.setState(initialState); + }, +}; + +/** + * Global brand state container. + * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. + * + * @type {Automat} + */ +brandAutomat = new Automat(initialState, actions, { name: 'brand' }); + +// Subscribe to auth changes +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) { + void brandAutomat.actions.load(); + } else if (auth.status === 'anon') { + brandAutomat.actions.reset(); + } +}); + +export default brandAutomat; diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js new file mode 100644 index 0000000..4f719b8 --- /dev/null +++ b/src/automats/categoryAutomat.js @@ -0,0 +1,490 @@ +import { Automat } from 'react-automat'; +import { category as categoryApi, article as articleApi } from '../api'; +import authAutomat from './authAutomat'; +import companyAutomat from './companyAutomat'; +import { + parseCategoryTreeResponse, + normalizeCategoryNode, + getCategoryBreadcrumbs, + getCategoryChildren, + ROOT_CATEGORY_NAME, +} from '../utils/categoryUtils'; + +/** + * @typedef {Object} CategoryState + * @property {number|string|null} companyId + * @property {string|null} languageId + * @property {Record|null} categories id → node map + * @property {number|string|null} rootCategoryId + * @property {Array} rootArticles + * @property {number|string|null} currentCategoryId + * @property {Array} articles + * @property {number} articlesTotal + * @property {boolean} renameMode + * @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus + * @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus + * @property {string|null} error + */ + +/** @type {CategoryState} */ +const initialState = { + companyId: null, + languageId: null, + categories: null, + rootCategoryId: null, + rootArticles: [], + currentCategoryId: null, + articles: [], + articlesTotal: 0, + renameMode: false, + treeStatus: 'idle', + articlesStatus: 'idle', + error: null, +}; + +let categoryAutomat; + +// ─── Pure helpers ────────────────────────────────────────────────────────────── + +function articlesForCategory(state, categoryId) { + if (String(categoryId) === String(state.rootCategoryId)) { + return state.rootArticles; + } + return []; +} + +// ─── Actions ─────────────────────────────────────────────────────────────────── + +const actions = { + /** Load the category tree for the currently selected company. */ + async loadTree() { + const company = companyAutomat.state.current; + if (!company) return; + const { id: companyId, rootCategoryId: companyRootId } = company; + if (!companyRootId) return; + + categoryAutomat.setState({ treeStatus: 'loading', error: null }); + try { + const data = await categoryApi.getTree(); + const parsed = parseCategoryTreeResponse(data, companyRootId); + categoryAutomat.setState({ + treeStatus: 'succeeded', + companyId, + languageId: parsed.languageId, + categories: parsed.categories, + rootCategoryId: parsed.rootCategoryId, + currentCategoryId: parsed.rootCategoryId, + rootArticles: parsed.rootArticles, + articles: parsed.rootArticles, + articlesTotal: parsed.rootArticles.length, + articlesStatus: 'succeeded', + error: null, + }); + } catch (err) { + categoryAutomat.setState({ + treeStatus: 'failed', + error: err?.message ?? String(err), + }); + throw err; + } + }, + + /** + * Load articles for a non-root category. + * @param {number|string} categoryId + */ + async loadArticles(categoryId) { + categoryAutomat.setState({ articlesStatus: 'loading', error: null }); + try { + const res = await categoryApi.getArticles(categoryId); + const articles = Array.isArray(res.articles) ? res.articles : []; + categoryAutomat.setState({ + articlesStatus: 'succeeded', + articles, + articlesTotal: articles.length, + }); + } catch (err) { + categoryAutomat.setState({ + articlesStatus: 'failed', + articles: [], + articlesTotal: 0, + error: err?.message ?? String(err), + }); + throw err; + } + }, + + /** Navigate to a category by id. */ + navigateTo(categoryId) { + const { rootCategoryId, rootArticles } = categoryAutomat.state; + const isRoot = String(categoryId) === String(rootCategoryId); + categoryAutomat.setState({ + currentCategoryId: categoryId, + error: null, + articles: isRoot ? rootArticles : [], + articlesTotal: isRoot ? rootArticles.length : 0, + articlesStatus: isRoot ? 'succeeded' : 'idle', + }); + }, + + /** Navigate up to the parent category. */ + navigateBack() { + const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state; + const current = categories?.[String(currentCategoryId)]; + const parentId = current?.parentId; + if (parentId == null || parentId === 0) return; + if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) { + const isRoot = String(parentId) === String(rootCategoryId); + categoryAutomat.setState({ + currentCategoryId: parentId, + error: null, + articles: isRoot ? rootArticles : [], + articlesTotal: isRoot ? rootArticles.length : 0, + articlesStatus: isRoot ? 'succeeded' : 'idle', + }); + } + }, + + /** Toggle rename/edit mode for the category grid. */ + setRenameMode(enabled) { + categoryAutomat.setState({ renameMode: Boolean(enabled) }); + }, + + // ─── Category mutations ──────────────────────────────────────────────────── + + /** + * Create a single category. + * @param {{ parentId: number|string, name: string }} param0 + */ + async createCategory({ parentId, name }) { + const res = await categoryApi.create({ parentId, name }); + const node = normalizeCategoryNode(res.category); + const { categories } = categoryAutomat.state; + categoryAutomat.setState({ + categories: { ...(categories ?? {}), [String(node.id)]: node }, + }); + return node; + }, + + /** + * Create multiple categories under the same parent. + * @param {{ parentId: number|string, names: string[] }} param0 + */ + async createCategories({ parentId, names }) { + const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean); + if (list.length === 0) throw new Error('Keine Namen übergeben.'); + const created = []; + for (const name of list) { + const res = await categoryApi.create({ parentId, name }); + created.push(normalizeCategoryNode(res.category)); + } + const patch = Object.fromEntries(created.map((n) => [String(n.id), n])); + categoryAutomat.setState({ + categories: { ...(categoryAutomat.state.categories ?? {}), ...patch }, + }); + return created; + }, + + /** + * Create a single article. + * @param {{ parentId: number|string, name: string }} param0 + */ + async createArticle({ parentId, name }) { + const res = await articleApi.create({ parentId, name }); + const created = res.article; + const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state; + const patch = {}; + if (String(created.parentId) === String(currentCategoryId)) { + patch.articles = [...articles, created]; + patch.articlesTotal = patch.articles.length; + patch.articlesStatus = 'succeeded'; + } + if (String(created.parentId) === String(rootCategoryId)) { + patch.rootArticles = [...rootArticles, created]; + } + if (Object.keys(patch).length) categoryAutomat.setState(patch); + return created; + }, + + /** + * Create multiple articles under the same parent. + * @param {{ parentId: number|string, names: string[] }} param0 + */ + async createArticles({ parentId, names }) { + const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean); + if (list.length === 0) throw new Error('Keine Namen übergeben.'); + const created = []; + for (const name of list) { + const res = await articleApi.create({ parentId, name }); + if (res.article) created.push(res.article); + } + const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state; + const patch = {}; + const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId)); + if (forCurrent.length > 0) { + patch.articles = [...articles, ...forCurrent]; + patch.articlesTotal = patch.articles.length; + patch.articlesStatus = 'succeeded'; + } + const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId)); + if (forRoot.length > 0) { + patch.rootArticles = [...rootArticles, ...forRoot]; + } + if (Object.keys(patch).length) categoryAutomat.setState(patch); + return created; + }, + + /** + * Rename a category. + * @param {{ categoryId: number|string, name: string }} param0 + */ + async renameCategory({ categoryId, name }) { + const trimmed = String(name ?? '').trim(); + if (!trimmed) throw new Error('Name required'); + const res = await categoryApi.updateName(categoryId, trimmed); + const updated = normalizeCategoryNode(res.category); + const key = String(updated.id); + const { categories, rootArticles } = categoryAutomat.state; + categoryAutomat.setState({ + categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null, + rootArticles: rootArticles.map((row) => + row?.categoryId != null && String(row.categoryId) === key + ? { ...row, name: updated.name } + : row, + ), + }); + return updated; + }, + + /** + * Reorder sibling categories. + * @param {{ categoryIds: number[] }} param0 + */ + async reorderCategories({ categoryIds }) { + const { currentCategoryId, categories } = categoryAutomat.state; + const res = await categoryApi.reorder(currentCategoryId, categoryIds); + const ordered = res.categoryIds; + const parentKey = String(currentCategoryId); + const idToSort = new Map(ordered.map((id, index) => [String(id), index])); + const patched = { ...categories }; + for (const node of Object.values(patched)) { + if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) { + patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) }; + } + } + categoryAutomat.setState({ categories: patched }); + return ordered; + }, + + /** + * Move a category under a new parent. + * @param {{ categoryId: number|string, parentId: number|string }} param0 + */ + async moveCategory({ categoryId, parentId }) { + const res = await categoryApi.move(categoryId, parentId); + const moved = res.category; + const { categories } = categoryAutomat.state; + if (!categories || !moved) return; + const key = String(moved.id); + categoryAutomat.setState({ + categories: { + ...categories, + [key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort }, + }, + }); + return moved; + }, + + /** + * Delete a category by id. + * @param {number|string} categoryId + */ + async deleteCategory(categoryId) { + await categoryApi.delete(categoryId); + const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state; + if (!categories) return; + const next = { ...categories }; + delete next[String(categoryId)]; + const patch = { categories: next }; + if (String(currentCategoryId) === String(categoryId)) { + patch.currentCategoryId = rootCategoryId; + patch.articles = rootArticles; + patch.articlesTotal = rootArticles.length; + patch.articlesStatus = 'succeeded'; + } + categoryAutomat.setState(patch); + return categoryId; + }, + + /** + * Toggle the POS flag on a category. + * @param {{ categoryId: number|string, pos: boolean }} param0 + */ + async toggleCategoryPos({ categoryId, pos }) { + const res = await categoryApi.updatePos(categoryId, pos); + const updated = normalizeCategoryNode(res.category); + const { categories } = categoryAutomat.state; + if (!categories) return; + const key = String(updated.id); + categoryAutomat.setState({ + categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } }, + }); + return updated; + }, + + /** + * Upload an image for a category. + * @param {{ categoryId: number|string, file: Blob }} param0 + */ + async uploadCategoryImage({ categoryId, file }) { + const res = await categoryApi.uploadImage(categoryId, file); + const { id, fullHash, thumbW, thumbH, thumbHash } = res.category; + const { categories } = categoryAutomat.state; + if (!categories) return res.category; + const key = String(id); + categoryAutomat.setState({ + categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } }, + }); + return res.category; + }, + + /** + * Delete the image for a category. + * @param {number|string} categoryId + */ + async deleteCategoryImage(categoryId) { + const res = await categoryApi.deleteImage(categoryId); + const { id } = res.category; + const { categories } = categoryAutomat.state; + if (!categories) return res.category; + const key = String(id); + categoryAutomat.setState({ + categories: { + ...categories, + [key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null }, + }, + }); + return res.category; + }, + + // ─── Local state patches ─────────────────────────────────────────────────── + + /** Remove an article from both article lists (used after article deletion). */ + removeArticleBySku(sku) { + categoryAutomat.setState({ + articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku), + articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length, + rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku), + }); + }, + + /** + * Patch article fields and category membership in local lists. + * @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload + */ + patchArticleInLists({ sku, categoryIds, ...fields }) { + if (sku == null) return; + const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state; + + const patchList = (list) => + list.map((row) => (row.sku !== sku ? row : { ...row, ...fields })); + + let nextArticles = patchList(articles); + let nextRoot = patchList(rootArticles); + let nextArticlesTotal = nextArticles.length; + + if (categoryIds !== undefined) { + const newIds = categoryIds ?? []; + const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku); + + if (rootCategoryId != null) { + const inRoot = newIds.some((id) => String(id) === String(rootCategoryId)); + const wasInRoot = nextRoot.some((r) => r.sku === sku); + if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku); + else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj]; + } + + if (currentCategoryId != null) { + const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId)); + const wasInCurrent = nextArticles.some((r) => r.sku === sku); + if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); } + else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; } + nextArticlesTotal = nextArticles.length; + } + } + + categoryAutomat.setState({ + articles: nextArticles, + articlesTotal: nextArticlesTotal, + rootArticles: nextRoot, + }); + }, + + /** + * Patch posterminalIds on a category node. + * @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0 + */ + patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) { + const { categories } = categoryAutomat.state; + const cat = categories?.[String(categoryId)]; + if (!cat) return; + const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : []; + const next = enabled + ? current.includes(terminalId) ? current : [...current, terminalId] + : current.filter((id) => id !== terminalId); + categoryAutomat.setState({ + categories: { + ...categories, + [String(categoryId)]: { ...cat, posterminalIds: next }, + }, + }); + }, + + /** Reset state to idle (logout / company switch). */ + reset() { + categoryAutomat.setState(initialState); + }, +}; + +/** + * Global category tree state container. + * Loads automatically when companyAutomat has a company with a rootCategoryId. + * + * @type {Automat} + */ +categoryAutomat = new Automat(initialState, actions, { name: 'category' }); + +// Load tree whenever the company data changes (or first becomes available) +let _lastCompanyId = null; +companyAutomat.subscribe((company) => { + const id = company.current?.id ?? null; + const rootCategoryId = company.current?.rootCategoryId ?? null; + if (id && rootCategoryId && id !== _lastCompanyId) { + _lastCompanyId = id; + void categoryAutomat.actions.loadTree(); + } + if (!id && _lastCompanyId) { + _lastCompanyId = null; + categoryAutomat.actions.reset(); + } +}); + +// Reset tree on logout +authAutomat.subscribe((auth) => { + if (auth.status === 'anon') { + _lastCompanyId = null; + categoryAutomat.actions.reset(); + } +}); + +// Re-export pure helpers for consumers that previously imported from categorySlice +export { + ROOT_CATEGORY_NAME, + parseCategoryTreeResponse, + normalizeCategoryNode, + getCategoryBreadcrumbs, + getCategoryChildren, +} from '../utils/categoryUtils'; +export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils'; + +export default categoryAutomat; diff --git a/src/automats/companyAutomat.js b/src/automats/companyAutomat.js new file mode 100644 index 0000000..c219104 --- /dev/null +++ b/src/automats/companyAutomat.js @@ -0,0 +1,96 @@ +import { Automat } from 'react-automat'; +import { company as companyApi } from '../api'; +import authAutomat from './authAutomat'; +import { normalizeCompany } from '../utils/companyForm'; + +/** + * @typedef {Object} CompanyState + * @property {object|null} current Normalized company object for the selected company. + * @property {'idle'|'loading'|'succeeded'|'failed'} status + * @property {string|null} error + */ + +/** @type {CompanyState} */ +const initialState = { + current: null, + status: 'idle', + error: null, +}; + +let companyAutomat; + +const actions = { + /** + * Load the company data for the currently selected company. + * @returns {Promise} + */ + async load() { + const companyId = authAutomat.state.user?.selectedCompanyId; + if (!companyId) return null; + companyAutomat.setState({ status: 'loading', error: null }); + try { + const data = await companyApi.getById(companyId); + const normalized = normalizeCompany(data.company); + companyAutomat.setState({ current: normalized, status: 'succeeded' }); + return normalized; + } catch (err) { + companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } + }, + + /** + * Update company details and refresh cached data. + * @param {object} payload + */ + async update(payload) { + const companyId = authAutomat.state.user?.selectedCompanyId; + if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); + companyAutomat.setState({ status: 'loading', error: null }); + try { + await companyApi.update(companyId, payload); + const data = await companyApi.getById(companyId); + const normalized = normalizeCompany(data.company, payload); + companyAutomat.setState({ current: normalized, status: 'succeeded' }); + return normalized; + } catch (err) { + companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } + }, + + /** Reset to idle (called on logout / company switch). */ + reset() { + companyAutomat.setState(initialState); + }, +}; + +/** + * Global company state container. + * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. + * + * @type {Automat} + */ +companyAutomat = new Automat(initialState, actions, { name: 'company' }); + +// Subscribe to auth changes — load when authed, reset when logged out / anon +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) { + void companyAutomat.actions.load(); + } else if (auth.status === 'anon') { + companyAutomat.actions.reset(); + } +}); + +// Selectors (plain functions for use outside React) +export const selectCurrentCompany = () => companyAutomat.state.current; +export const selectCompanyId = () => companyAutomat.state.current?.id ?? null; +export const selectCompanyStatus = () => companyAutomat.state.status; +export const selectCompanyName = () => companyAutomat.state.current?.name; +export const selectCompanyIso639 = () => companyAutomat.state.current?.iso639; +export const selectCompanyTaxrate = () => companyAutomat.state.current?.taxrate ?? null; +export const selectCompanyReducedTaxrate = () => companyAutomat.state.current?.reducedtaxrate ?? null; +export const selectCompanyRootCategoryId = () => companyAutomat.state.current?.rootCategoryId ?? null; + +export default companyAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index fe94911..e90d14d 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -1,11 +1,16 @@ export * from './deliveryEditorAutomat'; + export { default as authAutomat } from './authAutomat'; export * from './authAutomat'; + export { default as navigationAutomat } from './navigationAutomat'; export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; + export { default as userSettingsAutomat } from './userSettingsAutomat'; export { setViewerPosition } from './userSettingsAutomat'; + export { default as supplierAutomat } from './supplierAutomat'; + export { default as artikelAutomat } from './artikelAutomat'; export { getArticle, @@ -13,3 +18,37 @@ export { selectArticle, createArticleSelector, } from './artikelAutomat'; + +export { default as companyAutomat } from './companyAutomat'; +export { + selectCurrentCompany, + selectCompanyId, + selectCompanyStatus, + selectCompanyName, + selectCompanyIso639, + selectCompanyTaxrate, + selectCompanyReducedTaxrate, + selectCompanyRootCategoryId, +} from './companyAutomat'; + +export { default as brandAutomat } from './brandAutomat'; +export { default as unitAutomat } from './unitAutomat'; + +export { default as posterminalAutomat } from './posterminalAutomat'; +export { + selectPosterminals, + selectPosterminalStatus, + selectHasPosterminals, +} from './posterminalAutomat'; + +export { default as categoryAutomat } from './categoryAutomat'; +export { + ROOT_CATEGORY_NAME, + parseCategoryTreeResponse, + normalizeCategoryNode, + getCategoryBreadcrumbs, + getCategoryChildren, + categoryDisplayName, + categorySlug, + normalizeCategoryMap, +} from './categoryAutomat'; diff --git a/src/automats/posterminalAutomat.js b/src/automats/posterminalAutomat.js new file mode 100644 index 0000000..6395c10 --- /dev/null +++ b/src/automats/posterminalAutomat.js @@ -0,0 +1,142 @@ +import { Automat } from 'react-automat'; +import { posterminal as posterminalApi } from '../api'; +import authAutomat from './authAutomat'; +import companyAutomat from './companyAutomat'; + +/** + * @typedef {Object} PosterminalState + * @property {Array} terminals + * @property {number|string|null} companyId + * @property {'idle'|'loading'|'succeeded'|'failed'} status + * @property {string|null} error + */ + +/** @type {PosterminalState} */ +const initialState = { + terminals: [], + companyId: null, + status: 'idle', + error: null, +}; + +let posterminalAutomat; + +const actions = { + /** Load all posterminals for the selected company. */ + async load() { + const companyId = companyAutomat.state.current?.id; + if (!companyId) return []; + posterminalAutomat.setState({ status: 'loading', error: null }); + try { + const res = await posterminalApi.list(); + const terminals = Array.isArray(res.posterminals) ? res.posterminals : []; + posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' }); + return terminals; + } catch (err) { + posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } + }, + + /** + * Create a new posterminal. + * @param {{ name: string }} param0 + */ + async create({ name }) { + const res = await posterminalApi.create({ name }); + const terminal = res.posterminal; + posterminalAutomat.setState({ + terminals: [...posterminalAutomat.state.terminals, terminal], + }); + return terminal; + }, + + /** + * Delete a posterminal. + * @param {number} terminalId + */ + async delete(terminalId) { + await posterminalApi.delete(terminalId); + posterminalAutomat.setState({ + terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId), + }); + }, + + /** + * Enable all categories on a terminal. + * @param {{ terminalId: number }} param0 + */ + async enableAll({ terminalId }) { + const res = await posterminalApi.enableAllCategories(terminalId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + /** + * Disable all categories on a terminal. + * @param {{ terminalId: number }} param0 + */ + async disableAll({ terminalId }) { + const res = await posterminalApi.disableAllCategories(terminalId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + /** + * Enable a specific category on a terminal. + * @param {{ terminalId: number, categoryId: number }} param0 + */ + async enableCategory({ terminalId, categoryId }) { + const res = await posterminalApi.enableCategory(terminalId, categoryId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + /** + * Disable a specific category on a terminal. + * @param {{ terminalId: number, categoryId: number }} param0 + */ + async disableCategory({ terminalId, categoryId }) { + const res = await posterminalApi.disableCategory(terminalId, categoryId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + /** Reset to idle state (called on logout / company switch). */ + reset() { + posterminalAutomat.setState(initialState); + }, +}; + +function patchTerminalCategories(terminalId, categoryIds) { + posterminalAutomat.setState({ + terminals: posterminalAutomat.state.terminals.map((t) => + t.id === terminalId ? { ...t, categoryIds } : t, + ), + }); +} + +/** + * Global posterminal state container. + * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. + * + * @type {Automat} + */ +posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' }); + +// Subscribe to auth changes +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) { + void posterminalAutomat.actions.load(); + } else if (auth.status === 'anon') { + posterminalAutomat.actions.reset(); + } +}); + +// Selectors +export const selectPosterminals = () => posterminalAutomat.state.terminals; +export const selectPosterminalStatus = () => posterminalAutomat.state.status; +export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0; + +export default posterminalAutomat; diff --git a/src/automats/unitAutomat.js b/src/automats/unitAutomat.js new file mode 100644 index 0000000..fcc3fb2 --- /dev/null +++ b/src/automats/unitAutomat.js @@ -0,0 +1,94 @@ +import { Automat } from 'react-automat'; +import { unit as unitApi } from '../api'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} UnitState + * @property {Array} units + * @property {'idle'|'loading'|'succeeded'|'failed'} status + * @property {string|null} error + */ + +/** @type {UnitState} */ +const initialState = { + units: [], + status: 'idle', + error: null, +}; + +function sortUnits(units) { + return [...units].sort((a, b) => { + const left = a.symbol ?? a.si ?? ''; + const right = b.symbol ?? b.si ?? ''; + return String(left).localeCompare(String(right), 'de'); + }); +} + +let unitAutomat; + +const actions = { + /** Load all units for the selected company. */ + async load() { + unitAutomat.setState({ status: 'loading', error: null }); + try { + const res = await unitApi.list(); + const units = sortUnits(Array.isArray(res.units) ? res.units : []); + unitAutomat.setState({ units, status: 'succeeded' }); + return units; + } catch (err) { + unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } + }, + + /** + * Upsert (create or update) a unit. + * @param {object} body + */ + async upsert(body) { + const res = await unitApi.upsert(body); + const row = res.unit; + const units = sortUnits([ + ...unitAutomat.state.units.filter((u) => u.si !== row.si), + row, + ]); + unitAutomat.setState({ units }); + return units; + }, + + /** + * Delete a unit by SI symbol. + * @param {string} si + */ + async delete(si) { + await unitApi.delete(si); + unitAutomat.setState({ + units: unitAutomat.state.units.filter((u) => u.si !== si), + }); + }, + + /** Reset to idle state (called on logout / company switch). */ + reset() { + unitAutomat.setState(initialState); + }, +}; + +/** + * Global unit state container. + * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. + * + * @type {Automat} + */ +unitAutomat = new Automat(initialState, actions, { name: 'unit' }); + +// Subscribe to auth changes +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) { + void unitAutomat.actions.load(); + } else if (auth.status === 'anon') { + unitAutomat.actions.reset(); + } +}); + +export default unitAutomat; diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index 06eaa13..05d82ee 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -1,6 +1,5 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } 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'; @@ -14,13 +13,7 @@ import { enqueueSnackbar } from 'notistack'; import PageLayout from './PageLayout'; import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav'; import { isKategorienPath } from '../pages/artikel/categoryRoute'; -import { - selectRenameMode, - setRenameMode, - selectCategoryMap, - selectCategoryRootId, - loadCategoryTree, -} from '../store/categorySlice'; +import categoryAutomat from '../automats/categoryAutomat'; import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill'; import { article, category } from '../api'; import { importArtikelCsv } from '../utils/importArtikel'; @@ -37,15 +30,31 @@ function triggerDownload(blob, filename) { URL.revokeObjectURL(url); } +function selectCatSlice(s) { + return { + renameMode: s.renameMode, + categories: s.categories, + rootCategoryId: s.rootCategoryId, + }; +} + export default function SectionLayout({ sectionPath }) { const { pathname } = useLocation(); - const dispatch = useDispatch(); - const renameMode = useSelector(selectRenameMode); - const categories = useSelector(selectCategoryMap); - const rootCategoryId = useSelector(selectCategoryRootId); const importInputRef = useRef(null); const [extrasAnchor, setExtrasAnchor] = useState(null); const [importing, setImporting] = useState(false); + const [catState, setCatState] = useState(() => selectCatSlice(categoryAutomat.state)); + + useEffect(() => { + // Automat supports callback subscriptions: (state, partial) => void + const unsub = categoryAutomat.subscribe((state) => { + setCatState(selectCatSlice(state)); + }, selectCatSlice); + return unsub; + }, []); + + const { renameMode, categories, rootCategoryId } = catState; + const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? ''; const subNav = SUB_NAV[sectionPath]; const subSegment = resolveActiveSub(pathname, sectionPath); @@ -109,7 +118,7 @@ export default function SectionLayout({ sectionPath }) { }, }); if (result.ok > 0 && rootCategoryId != null) { - await dispatch(loadCategoryTree()).unwrap(); + await categoryAutomat.actions.loadTree(); } if (result.ok > 0) { enqueueSnackbar( @@ -136,7 +145,7 @@ export default function SectionLayout({ sectionPath }) { dispatch(setRenameMode(checked))} + onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)} /> c.id === companyId) ?? null; + return store.company ?? null; } class PriceAmountField extends Component { @@ -395,18 +390,12 @@ export default class ArticleViewPrice extends PureComponent { async load() { const store = this.context; try { - const [unitList, companies, session] = await Promise.all([ + const [unitList, company] = await Promise.all([ store.unitPromise, store.companyPromise, - store.authMePromise, ]); - const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId'); - const current = requireDefined( - companies.find((c) => c.id === companyId), - 'company', - ); if (!this._cancelled) { - this.setState({ units: unitList, company: current }); + this.setState({ units: unitList, company: company ?? null }); } } catch (err) { if (!this._cancelled) notifyError(err); diff --git a/src/components/category/NewCategoryTile.jsx b/src/components/category/NewCategoryTile.jsx index 667becf..36f364d 100644 --- a/src/components/category/NewCategoryTile.jsx +++ b/src/components/category/NewCategoryTile.jsx @@ -1,10 +1,6 @@ import { Component } from 'react'; -import { connect } from 'react-redux'; import { ActionTile } from '../tiles'; -import { - createCategories as createCategoriesThunk, - createCategory as createCategoryThunk, -} from '../../store/categorySlice'; +import categoryAutomat from '../../automats/categoryAutomat'; import CategoryNameDialog from './CategoryNameDialog'; class NewCategoryTile extends Component { @@ -28,10 +24,10 @@ class NewCategoryTile extends Component { const { parentId } = this.props; const list = (names ?? []).filter(Boolean); if (list.length <= 1) { - await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap(); + await categoryAutomat.actions.createCategory({ parentId, name: list[0] ?? '' }); return; } - await this.props.createCategories({ parentId, names: list }).unwrap(); + await categoryAutomat.actions.createCategories({ parentId, names: list }); } render() { @@ -55,9 +51,4 @@ class NewCategoryTile extends Component { } } -const mapDispatchToProps = { - createCategory: createCategoryThunk, - createCategories: createCategoriesThunk, -}; - -export default connect(null, mapDispatchToProps)(NewCategoryTile); +export default NewCategoryTile; diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index d0656db..d69b29b 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -8,8 +8,43 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import SyncIcon from '@mui/icons-material/Sync'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles'; +import { generateTextThumbnail } from '../../utils/textThumbnail'; export class Attachment extends PureComponent { + state = { + generatedThumbnail: null, + }; + + componentDidMount() { + this.ensureThumbnail(); + } + + componentDidUpdate(prevProps) { + if (this.props.attachment !== prevProps.attachment) { + this.ensureThumbnail(); + } + } + + async ensureThumbnail() { + const { attachment } = this.props; + if (!attachment || attachment.thumbnail) return; + const isCsv = attachment.type === 'text/csv' || attachment.name?.toLowerCase().endsWith('.csv'); + const isText = attachment.type?.startsWith('text/') || attachment.name?.toLowerCase().endsWith('.txt') || isCsv; + if (!isText) return; + + const source = attachment.text || attachment.file; + if (!source) return; + + try { + const thumb = await generateTextThumbnail(source); + if (thumb && this.props.attachment === attachment) { + this.setState({ generatedThumbnail: thumb }); + } + } catch (err) { + console.error('Fehler beim Generieren des Text-Thumbnails:', err); + } + } + handleRescan = (e) => { e?.stopPropagation?.(); const { attachment, onRescan } = this.props; @@ -56,7 +91,7 @@ export class Attachment extends PureComponent { render() { const { attachment, disabled, sx } = this.props; - const thumbnail = attachment?.thumbnail; + const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail; const name = attachment?.name || 'Dokument'; const size = attachment?.size; const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index 5eefc1a..fa05554 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -341,10 +341,18 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {} } } + let thumbavif = null; + if (attachment.thumbnail) { + thumbavif = attachment.thumbnail.includes(',') + ? attachment.thumbnail.split(',')[1] + : attachment.thumbnail; + } + files.push({ file: fileToUpload, name: attachment.name || fileToUpload.name, mimetype: attachment.type || fileToUpload.type, + thumbavif, }); } return files; @@ -389,6 +397,10 @@ export async function buildManifestBody( const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate }); if (filesToUpload.length > 0) { const formData = new FormData(); + body.filesMeta = filesToUpload.map((item) => ({ + name: item.name, + thumbavif: item.thumbavif ?? null, + })); formData.append('data', JSON.stringify(body)); for (const item of filesToUpload) { formData.append('files', item.file, item.name); diff --git a/src/main.jsx b/src/main.jsx index f69306b..cdac534 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,12 +3,10 @@ import { lazy, Suspense } from 'react'; import Box from '@mui/material/Box'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; -import { Provider } from 'react-redux'; import { SnackbarProvider } from 'notistack'; import AuthBootstrap from './components/AuthBootstrap'; import { StorageManagerProvider } from './api/storageManagerContext'; import App from './App'; -import { store } from './store'; import { getActiveTheme } from './theme'; import './automats/supplierAutomat'; import './index.css'; @@ -34,31 +32,29 @@ if (import.meta.env.DEV) { } createRoot(document.getElementById('root')).render( - - - - - - - {/* Flex row so the dev inspector docks beside the app instead of - overlaying it; the app content shrinks while the panel is open. */} - - - - - {AutomatInspector ? ( - - - - ) : null} + + + + + + {/* Flex row so the dev inspector docks beside the app instead of + overlaying it; the app content shrinks while the panel is open. */} + + + - - - - - , + {AutomatInspector ? ( + + + + ) : null} + + + + + , ); diff --git a/src/pages/artikel/categoryRoute.js b/src/pages/artikel/categoryRoute.js index 79b7aea..4490367 100644 --- a/src/pages/artikel/categoryRoute.js +++ b/src/pages/artikel/categoryRoute.js @@ -3,7 +3,7 @@ import { categorySlug, getCategoryBreadcrumbs, getCategoryChildren, -} from '../../store/categorySlice'; +} from '../../utils/categoryUtils'; export { ROOT_CATEGORY_NAME, categorySlug }; diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 6e45622..a94f08f 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -1,5 +1,4 @@ -import { Component, createRef } from 'react'; -import { connect } from 'react-redux'; +import { Component, PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import TextField from '@mui/material/TextField'; @@ -19,41 +18,49 @@ import { import { TILE_SIZE } from 'dashgui/tiles'; import withCategoryRoute from './withCategoryRoute'; import { artikelDetailPath, articleSku } from './articleRoute'; -import { selectCompanyId } from '../../store/companySlice'; -import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice'; import { buildCategoryPath, defaultCategoryPath, resolveCategoryIdFromSlugPath, ROOT_CATEGORY_NAME, } from './categoryRoute'; -import { - categoryDisplayName, - deleteCategory, - moveCategory, - loadCategoryArticles, - loadCategoryTree, - navigateToCategory, - renameCategory, - reorderCategories, - selectCanNavigateBack, - selectCategoryArticles, - selectCategoryArticlesStatus, - selectCategoryBreadcrumbs, - selectCategoryMap, - selectCategoryError, - selectCategoryTreeStatus, - selectChildCategories, - selectRenameMode, - patchCategoryPosterminalIds, - uploadCategoryImage, - deleteCategoryImage, -} from '../../store/categorySlice'; -import { - enableTerminalCategory, - disableTerminalCategory, -} from '../../store/posterminalSlice'; +import categoryAutomat from '../../automats/categoryAutomat'; +import posterminalAutomat from '../../automats/posterminalAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { setLastArticleSku } from '../../automats'; +import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils'; + +// ─── Helper selectors for this page ──────────────────────────────────────────── +function selectFromCategory(s) { + const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s; + const breadcrumbs = getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId); + const childCategories = getCategoryChildren(categories, currentCategoryId); + const canGoBack = + currentCategoryId != null && + rootCategoryId != null && + String(currentCategoryId) !== String(rootCategoryId) && + categories?.[String(currentCategoryId)]?.parentId != null && + categories[String(currentCategoryId)].parentId !== 0; + + return { + categories, + rootCategoryId, + treeStatus, + articlesStatus, + error, + currentCategoryId, + breadcrumbs, + childCategories, + articles, + articlesTotal, + canGoBack, + renameMode, + }; +} + +function selectTerminals(s) { + return { terminals: s.terminals }; +} const gridSx = { display: 'grid', @@ -61,10 +68,15 @@ const gridSx = { gap: 2, }; -class ArtikelKategorienPage extends Component { +class ArtikelKategorienPage extends PureComponent { constructor(props) { super(props); - this.state = { drafts: {} }; + this.state = { + drafts: {}, + ...selectFromCategory(categoryAutomat.state), + ...selectTerminals(posterminalAutomat.state), + companyId: companyAutomat.state.current?.id ?? null, + }; this.gridRef = createRef(); this.sortableModule = null; this.droppableModule = null; @@ -74,6 +86,11 @@ class ArtikelKategorienPage extends Component { componentDidMount() { setLastArticleSku(null); + this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory); + this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals); + this._unsubCompany = companyAutomat.subscribe(this, (s) => ({ + companyId: s.current?.id ?? null, + })); this.ensureTreeLoaded(); this.ensurePosterminalsLoaded(); this.syncCategoryFromUrl(); @@ -82,37 +99,36 @@ class ArtikelKategorienPage extends Component { this.syncDroppable(); } - componentDidUpdate(prevProps) { - if (this.props.companyId !== prevProps.companyId) { + componentDidUpdate(prevState) { + // Note: PureComponent — comparisons use this.state not this.props + if (this.state.companyId !== prevState.companyId) { this.ensureTreeLoaded(); } - - if ( - this.props.treeStatus === 'succeeded' && - prevProps.treeStatus !== 'succeeded' - ) { + if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') { this.syncCategoryFromUrl(); this.ensureArticlesLoaded(); } - if ( - this.props.treeStatus === 'succeeded' && - this.props.categorySlugPath !== prevProps.categorySlugPath + this.state.treeStatus === 'succeeded' && + this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath) ) { this.syncCategoryFromUrl(); } + this._prevSlugPath = this.props.categorySlugPath; - if (this.props.currentCategoryId !== prevProps.currentCategoryId) { + if (this.state.currentCategoryId !== prevState.currentCategoryId) { this.ensureArticlesLoaded(); } - - if (this.props.renameMode !== prevProps.renameMode) { + if (this.state.renameMode !== prevState.renameMode) { this.syncSortable(); this.syncDroppable(); } } componentWillUnmount() { + this._unsubCat?.(); + this._unsubTerm?.(); + this._unsubCompany?.(); this.sortableModule?.destroy(); this.droppableModule?.destroy(); } @@ -122,19 +138,21 @@ class ArtikelKategorienPage extends Component { if (!el) return; if (!this.sortableModule) { this.sortableModule = new CategorySortable(el, { - onReorder: this.props.reorderCategories, + onReorder: ({ categoryIds }) => + categoryAutomat.actions.reorderCategories({ categoryIds }), }); } if (!this.droppableModule) { this.droppableModule = new CategoryDroppable(el, { - onMove: this.props.moveCategory, + onMove: ({ categoryId, parentId }) => + categoryAutomat.actions.moveCategory({ categoryId, parentId }), }); } }; syncSortable = () => { this.#ensureModules(); - if (this.props.renameMode) { + if (this.state.renameMode) { this.sortableModule?.destroy(); this.sortableModule?.activate(); } else { @@ -144,7 +162,7 @@ class ArtikelKategorienPage extends Component { syncDroppable = () => { this.#ensureModules(); - if (this.props.renameMode) { + if (this.state.renameMode) { this.droppableModule?.destroy(); this.droppableModule?.activate(); } else { @@ -157,33 +175,32 @@ class ArtikelKategorienPage extends Component { }; ensureTreeLoaded = () => { - const { companyId, treeStatus } = this.props; + const { companyId, treeStatus } = this.state; if (!companyId || treeStatus !== 'idle') return; - this.props.loadCategoryTree(); + void categoryAutomat.actions.loadTree(); }; ensurePosterminalsLoaded = () => { - const { companyId } = this.props; + const { companyId } = this.state; if (!companyId) return; - this.props.loadPosterminals(); + void posterminalAutomat.actions.load(); }; ensureArticlesLoaded = () => { - const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props; + const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state; if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return; if (String(currentCategoryId) === String(rootCategoryId)) return; - this.props.loadCategoryArticles(currentCategoryId); + void categoryAutomat.actions.loadArticles(currentCategoryId); }; syncCategoryFromUrl = () => { const { categories, rootCategoryId, - categorySlugPath, currentCategoryId, treeStatus, - navigate, - } = this.props; + } = this.state; + const { categorySlugPath, navigate } = this.props; if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return; @@ -206,13 +223,13 @@ class ArtikelKategorienPage extends Component { } if (resolvedId !== currentCategoryId) { - this.props.navigateToCategory(resolvedId); + categoryAutomat.actions.navigateTo(resolvedId); } }; navigateToCategoryById = (categoryId) => { - const { categories, rootCategoryId, navigate } = this.props; - navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); + const { categories, rootCategoryId } = this.state; + this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); }; handleDraftChange(categoryId, value) { @@ -228,10 +245,7 @@ class ArtikelKategorienPage extends Component { return; } try { - await this.props.renameCategory({ - categoryId, - name: trimmed, - }).unwrap(); + await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed }); enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' }); } catch (err) { notifyError(err); @@ -240,7 +254,7 @@ class ArtikelKategorienPage extends Component { handleDeleteCategory = async (category) => { try { - await this.props.deleteCategory(category.id).unwrap(); + await categoryAutomat.actions.deleteCategory(category.id); enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); @@ -248,18 +262,18 @@ class ArtikelKategorienPage extends Component { }; handleRetryTree = () => { - if (this.props.companyId) this.props.loadCategoryTree(); + if (this.state.companyId) void categoryAutomat.actions.loadTree(); }; handleRetryArticles = () => { - const { currentCategoryId } = this.props; + const { currentCategoryId } = this.state; if (currentCategoryId) { - this.props.loadCategoryArticles(currentCategoryId); + void categoryAutomat.actions.loadArticles(currentCategoryId); } }; handleNavigateBack = () => { - const { breadcrumbs } = this.props; + const { breadcrumbs } = this.state; if (breadcrumbs.length < 2) return; this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id); }; @@ -283,7 +297,8 @@ class ArtikelKategorienPage extends Component { articles, renameMode, terminals, - } = this.props; + drafts, + } = this.state; if (!companyId) { return ( @@ -347,8 +362,8 @@ class ArtikelKategorienPage extends Component { {childCategories.map((item, index) => { const label = categoryDisplayName(item.name, item.id, rootCategoryId); const draftName = - this.state.drafts[item.id] !== undefined - ? this.state.drafts[item.id] + drafts[item.id] !== undefined + ? drafts[item.id] : label; const editProps = renameMode ? { @@ -359,13 +374,12 @@ class ArtikelKategorienPage extends Component { terminals, categoryPosterminalIds: item.posterminalIds || [], onTerminalToggle: (terminalId, enabled) => { - const thunk = enabled - ? this.props.enableTerminalCategory - : this.props.disableTerminalCategory; - void thunk({ terminalId, categoryId: item.id }) - .unwrap() + const action = enabled + ? posterminalAutomat.actions.enableCategory + : posterminalAutomat.actions.disableCategory; + void action({ terminalId, categoryId: item.id }) .then(() => { - this.props.patchCategoryPosterminalIds({ + categoryAutomat.actions.patchCategoryPosterminalIds({ categoryId: item.id, terminalId, enabled, @@ -382,10 +396,9 @@ class ArtikelKategorienPage extends Component { const file = e.target.files?.[0]; if (!file) return; try { - // Prepare image (resize and encode to AVIF) const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto'); const avifFile = await prepareArticlePhoto(file); - await this.props.uploadCategoryImage({ + await categoryAutomat.actions.uploadCategoryImage({ categoryId: item.id, file: avifFile, }); @@ -396,7 +409,7 @@ class ArtikelKategorienPage extends Component { input.click(); }, onPhotoDelete: () => { - void this.props.deleteCategoryImage(item.id) + void categoryAutomat.actions.deleteCategoryImage(item.id) .catch(notifyError); }, nameInput: ( @@ -478,36 +491,4 @@ class ArtikelKategorienPage extends Component { } } -const mapStateToProps = (state) => ({ - companyId: selectCompanyId(state), - categories: selectCategoryMap(state), - rootCategoryId: state.category.rootCategoryId, - treeStatus: selectCategoryTreeStatus(state), - articlesStatus: selectCategoryArticlesStatus(state), - error: selectCategoryError(state), - currentCategoryId: state.category.currentCategoryId, - breadcrumbs: selectCategoryBreadcrumbs(state), - childCategories: selectChildCategories(state), - articles: selectCategoryArticles(state), - canGoBack: selectCanNavigateBack(state), - renameMode: selectRenameMode(state), - terminals: selectPosterminals(state), -}); - -const mapDispatchToProps = { - loadCategoryTree, - loadCategoryArticles, - loadPosterminals, - navigateToCategory, - renameCategory, - deleteCategory, - moveCategory, - reorderCategories, - enableTerminalCategory, - disableTerminalCategory, - patchCategoryPosterminalIds, - uploadCategoryImage, - deleteCategoryImage, -}; - -export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); +export default withCategoryRoute(ArtikelKategorienPage); diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index dcc72cc..cb4d737 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -31,6 +31,7 @@ import { requireArray, requireDefined, requireObject } from '../../utils/apiShap import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { generatePdfThumbnail } from '../../utils/pdfThumbnail'; +import { generateTextThumbnail } from '../../utils/textThumbnail'; import { extractPdfText } from '../../utils/pdfText'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats'; @@ -477,12 +478,19 @@ class LieferungNeuPage extends PureComponent { const text = await extractPdfText(file, { layout: true }); const txtName = file.name.replace(/\.pdf$/i, '') + '.txt'; const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); + let txtThumbnail = null; + try { + txtThumbnail = await generateTextThumbnail(text); + } catch (thumbErr) { + console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr); + } const txtAttachment = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: txtFile, name: txtFile.name, size: txtFile.size, type: 'text/plain', + thumbnail: txtThumbnail, text, parentPdfId: pdfId, uploadedAt: new Date().toISOString(), @@ -499,6 +507,12 @@ class LieferungNeuPage extends PureComponent { } catch (err) { console.error('Fehler beim Lesen der Datei:', err); } + let thumbnail = null; + try { + thumbnail = await generateTextThumbnail(text || file); + } catch (thumbErr) { + console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr); + } const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const textAttachment = { id: attachmentId, @@ -506,6 +520,7 @@ class LieferungNeuPage extends PureComponent { name: file.name, size: file.size, type: isCsv ? 'text/csv' : 'text/plain', + thumbnail, text, uploadedAt: new Date().toISOString(), }; @@ -537,6 +552,13 @@ class LieferungNeuPage extends PureComponent { const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt'; const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); + let txtThumbnail = null; + try { + txtThumbnail = await generateTextThumbnail(text); + } catch (thumbErr) { + console.error('Fehler beim Aktualisieren des Text-Thumbnails:', thumbErr); + } + const existingTxt = this.state.attachments?.find( (a) => a.parentPdfId === attachment.id || a.name === txtName, ); @@ -546,6 +568,7 @@ class LieferungNeuPage extends PureComponent { file: txtFile, size: txtFile.size, text, + thumbnail: txtThumbnail || existingTxt.thumbnail, isDirty: true, }); } else { @@ -555,6 +578,7 @@ class LieferungNeuPage extends PureComponent { name: txtFile.name, size: txtFile.size, type: 'text/plain', + thumbnail: txtThumbnail, text, parentPdfId: attachment.id, uploadedAt: new Date().toISOString(), @@ -996,7 +1020,7 @@ class LieferungNeuPage extends PureComponent { ); } - handleSaveTextEditor(newText) { + async handleSaveTextEditor(newText) { const attachment = this.state.textEditorAttachment; if (!attachment) return; @@ -1004,11 +1028,19 @@ class LieferungNeuPage extends PureComponent { type: 'text/plain;charset=utf-8', }); + let thumbnail = attachment.thumbnail; + try { + thumbnail = await generateTextThumbnail(newText); + } catch (err) { + console.error('Fehler beim Aktualisieren des Text-Thumbnails:', err); + } + this.automat.actions.updateAttachment({ id: attachment.id, file: newFile, size: newFile.size, text: newText, + thumbnail, isDirty: true, }); diff --git a/src/store/brandSlice.js b/src/store/brandSlice.js deleted file mode 100644 index 511cb39..0000000 --- a/src/store/brandSlice.js +++ /dev/null @@ -1,94 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { brand as brandApi } from '../api'; -import authAutomat, { - AUTH_COMPANY_CHANGED, - AUTH_LOGGED_OUT, - AUTH_SESSION_ESTABLISHED, -} from '../automats/authAutomat'; -import { requireArray, requireObject } from '../utils/apiShape'; -import { catchBackendError } from '../utils/backendError'; - -function sortBrands(brands) { - return [...brands].sort((a, b) => - String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'), - ); -} - -export const loadBrands = createAsyncThunk('brand/load', async (_, { rejectWithValue }) => { - try { - const res = await brandApi.list(); - requireObject(res, 'GET /api/brand'); - return requireArray(res.brands, 'brands'); - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } -}); - -export const upsertBrand = createAsyncThunk( - 'brand/upsert', - async (body, { rejectWithValue }) => { - try { - const res = await brandApi.upsert(body); - requireObject(res, 'PUT /api/brand'); - requireObject(res.brand, 'brand'); - return res.brand; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -const initialState = { - brands: [], - status: 'idle', - error: null, -}; - -const brandSlice = createSlice({ - name: 'brand', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(loadBrands.pending, (state) => { - state.status = 'loading'; - state.error = null; - }) - .addCase(loadBrands.fulfilled, (state, action) => { - state.status = 'succeeded'; - state.brands = sortBrands(action.payload); - }) - .addCase(loadBrands.rejected, (state, action) => { - state.status = 'failed'; - state.error = action.error.message; - }) - .addCase(upsertBrand.fulfilled, (state, action) => { - const row = action.payload; - state.brands = sortBrands([ - ...state.brands.filter((b) => b.id !== row.id), - row, - ]); - }) - .addCase(AUTH_COMPANY_CHANGED, () => initialState) - .addCase(AUTH_LOGGED_OUT, () => initialState); - }, -}); - -export const selectBrands = (state) => state.brand.brands; -export const selectBrandsStatus = (state) => state.brand.status; -export const selectBrandsError = (state) => state.brand.error; - -export function brandLoadListener(listenerMiddleware) { - listenerMiddleware.startListening({ - matcher: (action) => - action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, - effect: async (_action, api) => { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (companyId) { - api.dispatch(loadBrands()); - } - }, - }); -} - -export default brandSlice.reducer; diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js deleted file mode 100644 index 7dddaac..0000000 --- a/src/store/categorySlice.js +++ /dev/null @@ -1,696 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { category, article } from '../api'; -import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; -import { catchBackendError, resolveBackendError } from '../utils/backendError'; -import { AUTH_LOGGED_OUT } from '../automats/authAutomat'; -import { loadCurrentCompany } from './companySlice'; - -export const ROOT_CATEGORY_NAME = 'Home'; - -export function normalizeCategoryNode(item) { - requireObject(item, 'category'); - requireDefined(item.id, 'category.id'); - return { - ...item, - id: item.id, - parentId: item.parentId, - name: item.name == null ? null : String(item.name).trim() || null, - posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [], - }; -} - -/** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */ -export function parseCategoryTreeResponse(data, companyRootCategoryId) { - requireObject(data, 'category/tree'); - const list = requireArray(data.tree, 'category/tree.tree'); - requireArray(data.articles, 'category/tree.articles'); - - const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId; - requireDefined(rootCategoryId, 'rootCategoryId'); - - const categories = Object.fromEntries( - list.map((item) => [String(item.id), normalizeCategoryNode(item)]), - ); - - return { - rootCategoryId, - categories, - rootArticles: data.articles, - languageId: data.languageId, - }; -} - -export function categoryDisplayName(name, categoryId, rootCategoryId) { - if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { - return ROOT_CATEGORY_NAME; - } - return name?.trim() || ''; -} - -export function categorySlug(node, rootCategoryId) { - if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) { - return ROOT_CATEGORY_NAME; - } - return node.slug || null; -} - -export function normalizeCategoryMap(data, companyRootCategoryId) { - return parseCategoryTreeResponse(data, companyRootCategoryId).categories; -} - -export function getCategoryChildren(categories, parentId) { - if (!categories) return []; - const parentKey = String(parentId); - return Object.values(categories) - .filter((item) => item.parentId != null && String(item.parentId) === parentKey) - .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); -} - -export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) { - if (!categories || currentCategoryId == null) return []; - - const crumbs = []; - const seen = new Set(); - let id = currentCategoryId; - - while (id != null && !seen.has(String(id))) { - seen.add(String(id)); - const node = categories[String(id)]; - if (!node) break; - - crumbs.unshift({ - id: node.id, - name: categoryDisplayName(node.name, node.id, rootCategoryId), - slug: categorySlug(node, rootCategoryId), - }); - - const parentId = node.parentId; - if (parentId == null || parentId === 0) break; - if (String(parentId) === String(id)) break; - id = parentId; - } - - return crumbs; -} - -export const loadCategoryTree = createAsyncThunk( - 'category/loadTree', - async (_, { rejectWithValue, getState }) => { - try { - const companyId = getState().company.current?.id; - const companyRootId = getState().company.current?.rootCategoryId; - requireDefined(companyRootId, 'company.rootCategoryId'); - const data = await category.getTree(); - const parsed = parseCategoryTreeResponse(data, companyRootId); - return { - companyId, - ...parsed, - }; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -export const loadCategoryArticles = createAsyncThunk( - 'category/loadArticles', - async (categoryId, { rejectWithValue }) => { - try { - const data = await category.getArticles(categoryId); - requireObject(data, `category/${categoryId}/articles`); - const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`); - return { categoryId, articles }; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -export const createCategory = createAsyncThunk( - 'category/create', - async ({ parentId, name }, { rejectWithValue }) => { - try { - const data = await category.create({ parentId, name }); - requireObject(data.category, 'POST /api/category.category'); - return normalizeCategoryNode(data.category); - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Create several categories under the same parent in one go (bulk add). - * Runs the single-create API sequentially so every category is created - * even if a later one fails; the first error is reported. - * Returns the array of created (normalized) categories. - */ -export const createCategories = createAsyncThunk( - 'category/createCategories', - async ({ parentId, names }, { rejectWithValue }) => { - const list = (names ?? []) - .map((n) => String(n ?? '').trim()) - .filter(Boolean); - if (list.length === 0) { - return rejectWithValue({ message: 'Keine Namen übergeben.' }); - } - const created = []; - try { - for (const name of list) { - const data = await category.create({ parentId, name }); - requireObject(data.category, 'POST /api/category.category'); - created.push(normalizeCategoryNode(data.category)); - } - return created; - } catch (err) { - const normalized = catchBackendError(rejectWithValue)(err); - // Attach already-created categories so the UI can still update optimistically. - return normalized && created.length > 0 - ? rejectWithValue({ ...normalized, created }) - : normalized; - } - }, -); - -export const createArticle = createAsyncThunk( - 'category/createArticle', - async ({ parentId, name }, { rejectWithValue }) => { - try { - const data = await article.create({ parentId, name }); - requireObject(data.article, 'POST /api/article.article'); - requireDefined(data.article.id, 'article.id'); - return data.article; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Create several articles under the same parent in one go (bulk add). - * Runs the single-create API sequentially so every article is created - * even if a later one fails; the first error is reported. - * Returns the array of created articles. - */ -export const createArticles = createAsyncThunk( - 'category/createArticles', - async ({ parentId, names }, { rejectWithValue }) => { - const list = (names ?? []) - .map((n) => String(n ?? '').trim()) - .filter(Boolean); - if (list.length === 0) { - return rejectWithValue({ message: 'Keine Namen übergeben.' }); - } - const created = []; - try { - for (const name of list) { - const data = await article.create({ parentId, name }); - requireObject(data.article, 'POST /api/article.article'); - requireDefined(data.article.id, 'article.id'); - created.push(data.article); - } - return created; - } catch (err) { - const normalized = catchBackendError(rejectWithValue)(err); - // Attach already-created articles so the UI can still update optimistically. - return normalized && created.length > 0 - ? rejectWithValue({ ...normalized, created }) - : normalized; - } - }, -); - -export const renameCategory = createAsyncThunk( - 'category/rename', - async ({ categoryId, name }, { rejectWithValue }) => { - try { - const trimmed = String(name ?? '').trim(); - if (!trimmed) throw new Error('Name required'); - const data = await category.updateName(categoryId, trimmed); - requireObject(data.category, 'PATCH /api/category/:id.category'); - return normalizeCategoryNode(data.category); - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Reorder sibling categories (new order of children under the current parent). - * `PUT /api/category/order` → 200 { categoryIds: number[] } - */ -export const reorderCategories = createAsyncThunk( - 'category/reorder', - async ({ categoryIds }, { rejectWithValue, getState }) => { - try { - const ids = requireArray(categoryIds, 'categoryIds'); - const parentId = getState().category.currentCategoryId; - requireDefined(parentId, 'currentCategoryId'); - const data = await category.reorder(parentId, ids); - requireObject(data, 'PUT /api/category/order'); - requireArray(data.categoryIds, 'categoryIds'); - return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') }; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Move a category under a new parent (drop into subcategory). - * Backend: PATCH /api/category/:id Body: { parentId } → { category }. - * (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.) - */ -export const moveCategory = createAsyncThunk( - 'category/move', - async ({ categoryId, parentId }, { rejectWithValue }) => { - try { - const data = await category.move(categoryId, parentId); - requireObject(data.category, 'PATCH /api/category/:id.category'); - return normalizeCategoryNode(data.category); - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -export const deleteCategory = createAsyncThunk( - 'category/delete', - async (categoryId, { rejectWithValue }) => { - try { - await category.delete(categoryId); - return categoryId; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Toggle the POS (Point of Sale) flag on a category. - * Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }. - * PROPOSED — backend needs to implement this field. - */ -export const toggleCategoryPos = createAsyncThunk( - 'category/togglePos', - async ({ categoryId, pos }, { rejectWithValue }) => { - try { - const data = await category.updatePos(categoryId, pos); - requireObject(data.category, 'PATCH /api/category/:id.category (pos)'); - return normalizeCategoryNode(data.category); - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * POST /api/category/:id/image → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } } - */ -export const uploadCategoryImage = createAsyncThunk( - 'category/uploadImage', - async ({ categoryId, file }, { rejectWithValue }) => { - try { - const data = await category.uploadImage(categoryId, file); - requireObject(data.category, 'POST /api/category/:id/image'); - return data.category; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * DELETE /api/category/:id/image → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } } - */ -export const deleteCategoryImage = createAsyncThunk( - 'category/deleteImage', - async (categoryId, { rejectWithValue }) => { - try { - const data = await category.deleteImage(categoryId); - requireObject(data.category, 'DELETE /api/category/:id/image'); - return data.category; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -const initialState = { - companyId: null, - languageId: null, - categories: null, - rootCategoryId: null, - rootArticles: [], - currentCategoryId: null, - articles: [], - articlesTotal: 0, - renameMode: false, - treeStatus: 'idle', - articlesStatus: 'idle', - error: null, -}; - -const categorySlice = createSlice({ - name: 'category', - initialState, - reducers: { - navigateToCategory(state, action) { - state.currentCategoryId = action.payload; - state.error = null; - if (String(action.payload) === String(state.rootCategoryId)) { - state.articles = state.rootArticles; - state.articlesTotal = state.rootArticles.length; - state.articlesStatus = 'succeeded'; - } else { - state.articles = []; - state.articlesTotal = 0; - state.articlesStatus = 'idle'; - } - }, - navigateBack(state) { - const current = state.categories?.[String(state.currentCategoryId)]; - const parentId = current?.parentId; - if (parentId == null || parentId === 0) return; - if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) { - state.currentCategoryId = parentId; - state.error = null; - if (String(parentId) === String(state.rootCategoryId)) { - state.articles = state.rootArticles; - state.articlesTotal = state.rootArticles.length; - state.articlesStatus = 'succeeded'; - } else { - state.articles = []; - state.articlesTotal = 0; - state.articlesStatus = 'idle'; - } - } - }, - resetCategoryState() { - return initialState; - }, - patchCategoryPosterminalIds(state, action) { - const { categoryId, terminalId, enabled } = action.payload; - const cat = state.categories?.[String(categoryId)]; - if (!cat) return; - if (!Array.isArray(cat.posterminalIds)) cat.posterminalIds = []; - if (enabled && !cat.posterminalIds.includes(terminalId)) { - cat.posterminalIds.push(terminalId); - } else if (!enabled) { - cat.posterminalIds = cat.posterminalIds.filter((id) => id !== terminalId); - } - }, - setRenameMode(state, action) { - state.renameMode = Boolean(action.payload); - }, - removeArticleBySku(state, action) { - const sku = action.payload; - state.articles = state.articles.filter((row) => row.sku !== sku); - state.articlesTotal = state.articles.length; - state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku); - }, - /** Keep category tile list in sync with article detail (e.g. after photo upload or category change). */ - patchArticleInLists(state, action) { - const { sku, categoryIds, ...fields } = action.payload; - if (sku == null) return; - - // Merge fields into existing article in both lists - const patchList = (list) => - list.map((row) => { - if (row.sku !== sku) return row; - return { ...row, ...fields }; - }); - state.articles = patchList(state.articles); - state.rootArticles = patchList(state.rootArticles); - - if (categoryIds === undefined) return; - const newCategoryIds = categoryIds ?? []; - - // Helper: find the article object in either list - const findArticle = (list) => list.find((row) => row.sku === sku); - const articleObj = findArticle(state.articles) || findArticle(state.rootArticles); - if (!articleObj) return; - - // Handle root category membership - if (state.rootCategoryId != null) { - const inRootCategory = newCategoryIds.some( - (id) => String(id) === String(state.rootCategoryId), - ); - const alreadyInRoot = state.rootArticles.some((row) => row.sku === sku); - if (!inRootCategory) { - state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku); - } else if (!alreadyInRoot) { - state.rootArticles = [...state.rootArticles, articleObj]; - } - } - - // Handle current category membership - if (state.currentCategoryId != null) { - const inCurrentCategory = newCategoryIds.some( - (id) => String(id) === String(state.currentCategoryId), - ); - const alreadyInCurrent = state.articles.some((row) => row.sku === sku); - if (!inCurrentCategory) { - state.articles = state.articles.filter((row) => row.sku !== sku); - state.articlesTotal = state.articles.length; - } else if (!alreadyInCurrent) { - state.articles = [...state.articles, articleObj]; - state.articlesTotal = state.articles.length; - } - } - }, - }, - extraReducers: (builder) => { - builder - .addCase(loadCategoryTree.pending, (state) => { - state.treeStatus = 'loading'; - state.error = null; - }) - .addCase(loadCategoryTree.fulfilled, (state, action) => { - state.treeStatus = 'succeeded'; - state.companyId = action.payload.companyId; - state.languageId = action.payload.languageId; - state.categories = action.payload.categories; - state.rootCategoryId = action.payload.rootCategoryId; - state.currentCategoryId = action.payload.rootCategoryId; - state.rootArticles = action.payload.rootArticles; - state.articles = state.rootArticles; - state.articlesTotal = state.rootArticles.length; - state.articlesStatus = 'succeeded'; - }) - .addCase(loadCategoryTree.rejected, (state, action) => { - state.treeStatus = 'failed'; - state.error = resolveBackendError(action); - }) - .addCase(loadCategoryArticles.pending, (state) => { - state.articlesStatus = 'loading'; - state.error = null; - }) - .addCase(loadCategoryArticles.fulfilled, (state, action) => { - state.articlesStatus = 'succeeded'; - state.articles = action.payload.articles; - state.articlesTotal = action.payload.articles.length; - }) - .addCase(loadCategoryArticles.rejected, (state, action) => { - state.articlesStatus = 'failed'; - state.articles = []; - state.articlesTotal = 0; - state.error = resolveBackendError(action); - }) - .addCase(createCategory.fulfilled, (state, action) => { - if (!state.categories) state.categories = {}; - state.categories[String(action.payload.id)] = action.payload; - }) - .addCase(createCategories.fulfilled, (state, action) => { - const createdList = action.payload; - if (!Array.isArray(createdList)) return; - if (!state.categories) state.categories = {}; - for (const item of createdList) { - if (item == null || item.id == null) continue; - state.categories[String(item.id)] = item; - } - }) - .addCase(renameCategory.fulfilled, (state, action) => { - const { id, name } = action.payload; - if (id == null || name == null) return; - const key = String(id); - const existing = state.categories?.[key]; - if (existing) { - state.categories[key] = { ...existing, name }; - } else if (state.categories) { - state.categories[key] = action.payload; - } - state.rootArticles = state.rootArticles.map((row) => - row?.categoryId != null && String(row.categoryId) === key - ? { ...row, name } - : row, - ); - }) - .addCase(reorderCategories.fulfilled, (state, action) => { - const { parentId, categoryIds } = action.payload; - if (!state.categories) return; - const parentKey = String(parentId); - const idToSort = new Map( - categoryIds.map((id, index) => [String(id), index]), - ); - for (const node of Object.values(state.categories)) { - if ( - node.parentId != null && - String(node.parentId) === parentKey && - idToSort.has(String(node.id)) - ) { - state.categories[String(node.id)] = { - ...node, - sort: idToSort.get(String(node.id)), - }; - } - } - }) - .addCase(moveCategory.fulfilled, (state, action) => { - const moved = action.payload; - if (moved == null || state.categories == null) return; - const existing = state.categories[String(moved.id)] ?? {}; - // Der Move-Response enthält nur { id, parentId, sort } — keinen Namen. - // parentId/sort werden übernommen, der bestehende Name bleibt erhalten - // (das Backend hat ihn beim Move nicht verändert). - state.categories[String(moved.id)] = { - ...existing, - parentId: moved.parentId, - sort: moved.sort, - }; - }) - .addCase(createArticle.fulfilled, (state, action) => { - const created = action.payload; - if (created == null) return; - if (String(created.parentId) === String(state.currentCategoryId)) { - state.articles = [...state.articles, created]; - state.articlesTotal = state.articles.length; - state.articlesStatus = 'succeeded'; - } - if (String(created.parentId) === String(state.rootCategoryId)) { - state.rootArticles = [...state.rootArticles, created]; - } - }) - .addCase(createArticles.fulfilled, (state, action) => { - const createdList = action.payload; - if (!Array.isArray(createdList)) return; - const added = createdList.filter((item) => item != null); - if (added.length === 0) return; - if (added.some((item) => String(item.parentId) === String(state.currentCategoryId))) { - state.articles = [ - ...state.articles, - ...added.filter((item) => - String(item.parentId) === String(state.currentCategoryId), - ), - ]; - state.articlesTotal = state.articles.length; - state.articlesStatus = 'succeeded'; - } - const rootAdded = added.filter((item) => - String(item.parentId) === String(state.rootCategoryId), - ); - if (rootAdded.length > 0) { - state.rootArticles = [...state.rootArticles, ...rootAdded]; - } - }) - .addCase(deleteCategory.fulfilled, (state, action) => { - if (!state.categories) return; - delete state.categories[String(action.payload)]; - if (String(state.currentCategoryId) === String(action.payload)) { - state.currentCategoryId = state.rootCategoryId; - state.articles = state.rootArticles; - state.articlesTotal = state.rootArticles.length; - state.articlesStatus = 'succeeded'; - } - }) - .addCase(toggleCategoryPos.fulfilled, (state, action) => { - const updated = action.payload; - if (updated == null || state.categories == null) return; - const key = String(updated.id); - const existing = state.categories[key]; - if (existing) { - state.categories[key] = { ...existing, pos: updated.pos }; - } - }) - .addCase(uploadCategoryImage.fulfilled, (state, action) => { - const { id, fullHash, thumbW, thumbH, thumbHash } = action.payload; - if (id == null || state.categories == null) return; - const key = String(id); - const existing = state.categories[key]; - if (existing) { - state.categories[key] = { ...existing, fullHash, thumbW, thumbH, thumbHash }; - } - }) - .addCase(deleteCategoryImage.fulfilled, (state, action) => { - const { id } = action.payload; - if (id == null || state.categories == null) return; - const key = String(id); - const existing = state.categories[key]; - if (existing) { - state.categories[key] = { - ...existing, - fullHash: null, - thumbW: null, - thumbH: null, - thumbHash: null, - }; - } - }) - .addCase(loadCurrentCompany.fulfilled, (state, action) => { - const nextCompanyId = action.payload?.id ?? null; - if (nextCompanyId != null && nextCompanyId !== state.companyId) { - return { - ...initialState, - companyId: nextCompanyId, - }; - } - }) - .addCase(AUTH_LOGGED_OUT, () => initialState); - }, -}); - -export const { - navigateToCategory, - navigateBack, - resetCategoryState, - setRenameMode, - removeArticleBySku, - patchArticleInLists, - patchCategoryPosterminalIds, -} = categorySlice.actions; - -export const selectRenameMode = (state) => state.category.renameMode; - -export const selectCategoryMap = (state) => state.category.categories; -export const selectCurrentCategoryId = (state) => state.category.currentCategoryId; -export const selectCategoryRootId = (state) => state.category.rootCategoryId; -export const selectCategoryTreeStatus = (state) => state.category.treeStatus; -export const selectCategoryArticles = (state) => state.category.articles; -export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus; -export const selectCategoryError = (state) => state.category.error; - -export const selectCurrentCategory = (state) => { - const id = state.category.currentCategoryId; - if (id == null) return null; - return state.category.categories?.[String(id)] ?? null; -}; - -export const selectChildCategories = (state) => - getCategoryChildren(state.category.categories, state.category.currentCategoryId); - -export const selectCategoryBreadcrumbs = (state) => - getCategoryBreadcrumbs( - state.category.categories, - state.category.currentCategoryId, - state.category.rootCategoryId, - ); - -export const selectCanNavigateBack = (state) => { - const { currentCategoryId, rootCategoryId, categories } = state.category; - if (currentCategoryId == null || rootCategoryId == null) return false; - if (String(currentCategoryId) === String(rootCategoryId)) return false; - const parentId = categories?.[String(currentCategoryId)]?.parentId; - return parentId != null && parentId !== 0; -}; - -export default categorySlice.reducer; diff --git a/src/store/companySlice.js b/src/store/companySlice.js deleted file mode 100644 index 79ae27c..0000000 --- a/src/store/companySlice.js +++ /dev/null @@ -1,98 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { company } from '../api'; -import authAutomat, { - AUTH_COMPANY_CHANGED, - AUTH_LOGGED_OUT, - AUTH_SESSION_ESTABLISHED, -} from '../automats/authAutomat'; -import { normalizeCompany } from '../utils/companyForm'; - -export const loadCurrentCompany = createAsyncThunk( - 'company/loadCurrent', - async () => { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (!companyId) return null; - const data = await company.getById(companyId); - return normalizeCompany(data.company); - }, -); - -export const updateCompany = createAsyncThunk( - 'company/update', - async (payload) => { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); - await company.update(companyId, payload); - const data = await company.getById(companyId); - return normalizeCompany(data.company, payload); - }, -); - -const initialState = { - current: null, - status: 'idle', - error: null, -}; - -const companySlice = createSlice({ - name: 'company', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(loadCurrentCompany.pending, (state) => { - state.status = 'loading'; - state.error = null; - }) - .addCase(loadCurrentCompany.fulfilled, (state, action) => { - state.status = 'succeeded'; - state.current = action.payload; - }) - .addCase(loadCurrentCompany.rejected, (state, action) => { - state.status = 'failed'; - state.error = action.error.message; - }) - .addCase(updateCompany.pending, (state) => { - state.status = 'loading'; - state.error = null; - }) - .addCase(updateCompany.fulfilled, (state, action) => { - state.status = 'succeeded'; - state.current = action.payload; - }) - .addCase(updateCompany.rejected, (state, action) => { - state.status = 'failed'; - state.error = action.error.message; - }) - .addCase(AUTH_LOGGED_OUT, (state) => { - state.current = null; - state.status = 'idle'; - state.error = null; - }); - }, -}); - -export const selectCurrentCompany = (state) => state.company.current; -export const selectCompanyId = (state) => state.company.current?.id ?? null; -export const selectCompanyStatus = (state) => state.company.status; -export const selectCompanyError = (state) => state.company.error; -export const selectCompanyName = (state) => state.company.current?.name; -export const selectCompanyIso639 = (state) => state.company.current?.iso639; -export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null; -export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null; -export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null; - -export function companyLoadListener(listenerMiddleware) { - listenerMiddleware.startListening({ - matcher: (action) => - action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, - effect: async (_action, api) => { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (companyId) { - api.dispatch(loadCurrentCompany()); - } - }, - }); -} - -export default companySlice.reducer; diff --git a/src/store/index.js b/src/store/index.js deleted file mode 100644 index 85a560e..0000000 --- a/src/store/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; -import { setAuthEventDispatcher } from '../automats/authAutomat'; -import brandReducer, { brandLoadListener } from './brandSlice'; -import categoryReducer from './categorySlice'; -import companyReducer, { companyLoadListener } from './companySlice'; -import posterminalReducer, { posterminalLoadListener } from './posterminalSlice'; -import unitReducer, { unitLoadListener } from './unitSlice'; - -const listenerMiddleware = createListenerMiddleware(); -companyLoadListener(listenerMiddleware); -unitLoadListener(listenerMiddleware); -brandLoadListener(listenerMiddleware); -posterminalLoadListener(listenerMiddleware); - -function reduxDevLogger() { - return (storeApi) => (next) => (action) => { - if ( - !import.meta.env.DEV || - (!action.type?.startsWith('auth/') && - !action.type?.startsWith('company/') && - !action.type?.startsWith('category/') && - !action.type?.startsWith('brand/') && - !action.type?.startsWith('posterminal/') && - !action.type?.startsWith('unit/')) - ) { - return next(action); - } - const result = next(action); - const after = storeApi.getState(); - console.log('[redux]', action.type, { - payload: action.payload, - company: after.company.current, - brands: after.brand.brands.length, - posterminals: after.posterminal.terminals.length, - units: after.unit.units.length, - }); - return result; - }; -} - -export const store = configureStore({ - reducer: { - company: companyReducer, - category: categoryReducer, - brand: brandReducer, - posterminal: posterminalReducer, - unit: unitReducer, - }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware() - .concat(listenerMiddleware.middleware) - .concat(import.meta.env.DEV ? reduxDevLogger() : []), -}); - - setAuthEventDispatcher(store.dispatch); diff --git a/src/store/posterminalSlice.js b/src/store/posterminalSlice.js deleted file mode 100644 index ae9c814..0000000 --- a/src/store/posterminalSlice.js +++ /dev/null @@ -1,267 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { posterminal } from '../api'; -import authAutomat, { - AUTH_COMPANY_CHANGED, - AUTH_LOGGED_OUT, - AUTH_SESSION_ESTABLISHED, -} from '../automats/authAutomat'; -import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; -import { catchBackendError, resolveBackendError } from '../utils/backendError'; -import { loadCurrentCompany } from './companySlice'; - -/** - * Load all posterminals for the selected company. - * GET /api/posterminal → { posterminals: [...] } - */ -export const loadPosterminals = createAsyncThunk( - 'posterminal/load', - async (_, { rejectWithValue, getState }) => { - try { - const companyId = getState().company.current?.id; - requireDefined(companyId, 'company.id'); - const data = await posterminal.list(); - requireObject(data, 'GET /api/posterminal'); - const list = requireArray(data.posterminals, 'posterminals'); - return { companyId, terminals: list }; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Create a new posterminal. - * PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } } - */ -export const createPosterminal = createAsyncThunk( - 'posterminal/create', - async ({ name }, { rejectWithValue }) => { - try { - const data = await posterminal.create({ name }); - requireObject(data, 'PUT /api/posterminal'); - requireObject(data.posterminal, 'posterminal'); - requireDefined(data.posterminal.id, 'posterminal.id'); - return data.posterminal; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Delete a posterminal. - * DELETE /api/posterminal/:id → 204 - */ -export const deletePosterminal = createAsyncThunk( - 'posterminal/delete', - async ({ terminalId }, { rejectWithValue }) => { - try { - await posterminal.delete(terminalId); - return terminalId; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Enable all categories on a terminal. - * PUT /api/posterminal/:id/all → { id, categoryIds } - */ -export const enableAllTerminalCategories = createAsyncThunk( - 'posterminal/enableAll', - async ({ terminalId }, { rejectWithValue }) => { - try { - const data = await posterminal.enableAllCategories(terminalId); - requireObject(data, 'PUT /api/posterminal/:id/all'); - return data; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Disable all categories on a terminal. - * DELETE /api/posterminal/:id/all → { id, categoryIds } - */ -export const disableAllTerminalCategories = createAsyncThunk( - 'posterminal/disableAll', - async ({ terminalId }, { rejectWithValue }) => { - try { - const data = await posterminal.disableAllCategories(terminalId); - requireObject(data, 'DELETE /api/posterminal/:id/all'); - return data; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Enable a specific category on a terminal. - * PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds } - */ -export const enableTerminalCategory = createAsyncThunk( - 'posterminal/enableCategory', - async ({ terminalId, categoryId }, { rejectWithValue }) => { - try { - const data = await posterminal.enableCategory(terminalId, categoryId); - requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId'); - return data; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -/** - * Disable a specific category on a terminal. - * DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds } - */ -export const disableTerminalCategory = createAsyncThunk( - 'posterminal/disableCategory', - async ({ terminalId, categoryId }, { rejectWithValue }) => { - try { - const data = await posterminal.disableCategory(terminalId, categoryId); - requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId'); - return data; - } catch (err) { - return catchBackendError(rejectWithValue)(err); - } - }, -); - -const initialState = { - companyId: null, - terminals: [], - status: 'idle', - error: null, -}; - -const posterminalSlice = createSlice({ - name: 'posterminal', - initialState, - reducers: { - resetPosterminalState() { - return initialState; - }, - }, - extraReducers: (builder) => { - builder - // Load - .addCase(loadPosterminals.pending, (state) => { - state.status = 'loading'; - state.error = null; - }) - .addCase(loadPosterminals.fulfilled, (state, action) => { - state.status = 'succeeded'; - state.companyId = action.payload.companyId; - state.terminals = action.payload.terminals; - }) - .addCase(loadPosterminals.rejected, (state, action) => { - state.status = 'failed'; - state.error = resolveBackendError(action); - }) - // Create - .addCase(createPosterminal.pending, (state) => { - state.error = null; - }) - .addCase(createPosterminal.fulfilled, (state, action) => { - state.terminals.push(action.payload); - }) - .addCase(createPosterminal.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Delete - .addCase(deletePosterminal.fulfilled, (state, action) => { - state.terminals = state.terminals.filter( - (t) => t.id !== action.payload, - ); - }) - .addCase(deletePosterminal.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Enable all categories - .addCase(enableAllTerminalCategories.fulfilled, (state, action) => { - const { id, categoryIds } = action.payload; - const terminal = state.terminals.find((t) => t.id === id); - if (terminal) { - terminal.categoryIds = categoryIds; - } - }) - .addCase(enableAllTerminalCategories.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Disable all categories - .addCase(disableAllTerminalCategories.fulfilled, (state, action) => { - const { id, categoryIds } = action.payload; - const terminal = state.terminals.find((t) => t.id === id); - if (terminal) { - terminal.categoryIds = categoryIds; - } - }) - .addCase(disableAllTerminalCategories.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Enable single category - .addCase(enableTerminalCategory.fulfilled, (state, action) => { - const { id, categoryIds } = action.payload; - const terminal = state.terminals.find((t) => t.id === id); - if (terminal) { - terminal.categoryIds = categoryIds; - } - }) - .addCase(enableTerminalCategory.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Disable single category - .addCase(disableTerminalCategory.fulfilled, (state, action) => { - const { id, categoryIds } = action.payload; - const terminal = state.terminals.find((t) => t.id === id); - if (terminal) { - terminal.categoryIds = categoryIds; - } - }) - .addCase(disableTerminalCategory.rejected, (state, action) => { - state.error = resolveBackendError(action); - }) - // Reset on company change - .addCase(loadCurrentCompany.fulfilled, (state, action) => { - const nextCompanyId = action.payload?.id ?? null; - if (nextCompanyId != null && nextCompanyId !== state.companyId) { - return { ...initialState, companyId: nextCompanyId, status: 'idle' }; - } - }) - // Reset on logout - .addCase(AUTH_LOGGED_OUT, () => initialState); - }, -}); - -export const { resetPosterminalState } = posterminalSlice.actions; - -/** - * Auto-load posterminals when the session/company is established. - * Mirrors brandLoadListener / unitLoadListener pattern. - */ -export function posterminalLoadListener(listenerMiddleware) { - listenerMiddleware.startListening({ - matcher: (action) => - action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, - effect: async (action, api) => { - const companyId = authAutomat.state.user?.selectedCompanyId; - console.log('[posterminalLoadListener]', action.type, { companyId }); - if (companyId) { - api.dispatch(loadPosterminals()); - } - }, - }); -} - -// Selectors -export const selectPosterminals = (state) => state.posterminal.terminals; -export const selectPosterminalStatus = (state) => state.posterminal.status; -export const selectPosterminalError = (state) => state.posterminal.error; -export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0; - -export default posterminalSlice.reducer; diff --git a/src/store/unitSlice.js b/src/store/unitSlice.js deleted file mode 100644 index 5d2304b..0000000 --- a/src/store/unitSlice.js +++ /dev/null @@ -1,92 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { unit as unitApi } from '../api'; -import authAutomat, { - AUTH_COMPANY_CHANGED, - AUTH_LOGGED_OUT, - AUTH_SESSION_ESTABLISHED, -} from '../automats/authAutomat'; -import { requireArray, requireObject } from '../utils/apiShape'; - -function sortUnits(units) { - return [...units].sort((a, b) => { - const left = a.symbol ?? a.si ?? ''; - const right = b.symbol ?? b.si ?? ''; - return String(left).localeCompare(String(right), 'de'); - }); -} - -export const loadUnits = createAsyncThunk('unit/load', async () => { - const res = await unitApi.list(); - requireObject(res, 'GET /api/unit'); - return requireArray(res.units, 'units'); -}); - -export const upsertUnit = createAsyncThunk('unit/upsert', async (body) => { - const res = await unitApi.upsert(body); - requireObject(res, 'PUT /api/unit'); - requireObject(res.unit, 'unit'); - return res.unit; -}); - -export const deleteUnit = createAsyncThunk('unit/delete', async (si) => { - await unitApi.delete(si); - return si; -}); - -const initialState = { - units: [], - status: 'idle', - error: null, -}; - -const unitSlice = createSlice({ - name: 'unit', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(loadUnits.pending, (state) => { - state.status = 'loading'; - state.error = null; - }) - .addCase(loadUnits.fulfilled, (state, action) => { - state.status = 'succeeded'; - state.units = sortUnits(action.payload); - }) - .addCase(loadUnits.rejected, (state, action) => { - state.status = 'failed'; - state.error = action.error.message; - }) - .addCase(upsertUnit.fulfilled, (state, action) => { - const row = action.payload; - state.units = sortUnits([ - ...state.units.filter((u) => u.si !== row.si), - row, - ]); - }) - .addCase(deleteUnit.fulfilled, (state, action) => { - state.units = state.units.filter((u) => u.si !== action.payload); - }) - .addCase(AUTH_COMPANY_CHANGED, () => initialState) - .addCase(AUTH_LOGGED_OUT, () => initialState); - }, -}); - -export const selectUnits = (state) => state.unit.units; -export const selectUnitsStatus = (state) => state.unit.status; -export const selectUnitsError = (state) => state.unit.error; - -export function unitLoadListener(listenerMiddleware) { - listenerMiddleware.startListening({ - matcher: (action) => - action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, - effect: async (_action, api) => { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (companyId) { - api.dispatch(loadUnits()); - } - }, - }); -} - -export default unitSlice.reducer; diff --git a/src/utils/categoryUtils.js b/src/utils/categoryUtils.js new file mode 100644 index 0000000..f84d3a3 --- /dev/null +++ b/src/utils/categoryUtils.js @@ -0,0 +1,111 @@ +/** + * Pure category tree helper functions. + * These operate on plain category maps and have no side-effects. + */ + +export const ROOT_CATEGORY_NAME = 'Home'; + +/** + * @param {object} item + * @returns {object} + */ +export function normalizeCategoryNode(item) { + return { + ...item, + id: item.id, + parentId: item.parentId, + name: item.name == null ? null : String(item.name).trim() || null, + posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [], + }; +} + +/** + * Parse the raw GET /api/category/tree response body into normalized state. + * @param {object} data + * @param {number|string|null} companyRootCategoryId + */ +export function parseCategoryTreeResponse(data, companyRootCategoryId) { + const list = Array.isArray(data.tree) ? data.tree : []; + const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId; + + const categories = Object.fromEntries( + list.map((item) => [String(item.id), normalizeCategoryNode(item)]), + ); + + return { + rootCategoryId, + categories, + rootArticles: Array.isArray(data.articles) ? data.articles : [], + languageId: data.languageId, + }; +} + +/** + * Display name for a category node (root → "Home"). + */ +export function categoryDisplayName(name, categoryId, rootCategoryId) { + if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { + return ROOT_CATEGORY_NAME; + } + return name?.trim() || ''; +} + +/** + * URL slug for a category node (root → "Home"). + */ +export function categorySlug(node, rootCategoryId) { + if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) { + return ROOT_CATEGORY_NAME; + } + return node.slug || null; +} + +/** + * Direct children of `parentId`, sorted by `sort` then `id`. + */ +export function getCategoryChildren(categories, parentId) { + if (!categories) return []; + const parentKey = String(parentId); + return Object.values(categories) + .filter((item) => item.parentId != null && String(item.parentId) === parentKey) + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); +} + +/** + * Breadcrumb trail from root to `currentCategoryId`. + */ +export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) { + if (!categories || currentCategoryId == null) return []; + + const crumbs = []; + const seen = new Set(); + let id = currentCategoryId; + + while (id != null && !seen.has(String(id))) { + seen.add(String(id)); + const node = categories[String(id)]; + if (!node) break; + + crumbs.unshift({ + id: node.id, + name: categoryDisplayName(node.name, node.id, rootCategoryId), + slug: categorySlug(node, rootCategoryId), + }); + + const parentId = node.parentId; + if (parentId == null || parentId === 0) break; + if (String(parentId) === String(id)) break; + id = parentId; + } + + return crumbs; +} + +/** + * Map of category.id → category node from a raw tree response. + * @param {object} data + * @param {number|string|null} companyRootCategoryId + */ +export function normalizeCategoryMap(data, companyRootCategoryId) { + return parseCategoryTreeResponse(data, companyRootCategoryId).categories; +} diff --git a/src/utils/textThumbnail.js b/src/utils/textThumbnail.js new file mode 100644 index 0000000..ac50701 --- /dev/null +++ b/src/utils/textThumbnail.js @@ -0,0 +1,144 @@ +let encodeAvif = null; + +async function getAvifEncoder() { + if (!encodeAvif) { + const { default: encode } = await import('@jsquash/avif/encode'); + encodeAvif = encode; + } + return encodeAvif; +} + +/** + * Converts an ArrayBuffer to a base64 string safely without stack overflow. + * @param {ArrayBuffer} buffer + * @returns {string} + */ +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 8192; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode.apply( + null, + bytes.subarray(i, i + chunkSize), + ); + } + return btoa(binary); +} + +/** + * Renders text onto a 2D canvas styled like a document page thumbnail. + * @param {string} text + * @param {number} [width=200] + * @param {number} [height=280] + * @returns {HTMLCanvasElement} + */ +export function renderTextToCanvas(text = '', width = 200, height = 280) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas; + + // Background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + + // Subtle border + ctx.strokeStyle = '#e2e8f0'; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, width - 1, height - 1); + + // Top header accent line + ctx.fillStyle = '#cbd5e1'; + ctx.fillRect(10, 8, 30, 2); + + // Render text lines + ctx.fillStyle = '#334155'; + ctx.font = '8px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; + + const paddingX = 10; + const startY = 22; + const lineHeight = 11; + const maxLines = Math.floor((height - startY - 8) / lineHeight); + const maxWidth = width - paddingX * 2; + + const rawLines = String(text || '').split(/\r?\n/); + const formattedLines = []; + + for (const line of rawLines) { + if (formattedLines.length >= maxLines) break; + const clean = line.replace(/\t/g, ' '); + if (!clean.trim()) { + formattedLines.push(''); + continue; + } + + // Wrap or clip long lines + let current = ''; + for (let i = 0; i < clean.length; i++) { + const candidate = current + clean[i]; + if (ctx.measureText(candidate).width > maxWidth) { + formattedLines.push(current); + current = clean[i]; + if (formattedLines.length >= maxLines) break; + } else { + current = candidate; + } + } + if (current && formattedLines.length < maxLines) { + formattedLines.push(current); + } + } + + for (let idx = 0; idx < formattedLines.length; idx++) { + const y = startY + idx * lineHeight; + ctx.fillText(formattedLines[idx], paddingX, y); + } + + return canvas; +} + +/** + * Generates an AVIF thumbnail data URL for text content. + * @param {string|File|Blob} textSource + * @param {{ width?: number, height?: number, quality?: number }} [options] + * @returns {Promise} Base64 Data URL (data:image/avif;base64,... or data:image/png;base64,...) + */ +export async function generateTextThumbnail(textSource, { width = 200, height = 280, quality = 70 } = {}) { + let text = ''; + if (textSource instanceof Blob) { + text = await textSource.text(); + } else if (typeof textSource === 'string') { + text = textSource; + } else if (textSource != null) { + text = String(textSource); + } + + if (typeof document === 'undefined') { + return null; + } + + const canvas = renderTextToCanvas(text, width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + try { + const imageData = ctx.getImageData(0, 0, width, height); + const encoder = await getAvifEncoder(); + const avifBuffer = await encoder(imageData, { + quality, + speed: 9, + }); + const base64 = arrayBufferToBase64(avifBuffer); + return `data:image/avif;base64,${base64}`; + } catch (err) { + console.warn('generateTextThumbnail: @jsquash/avif encode failed, falling back to PNG:', err); + try { + return canvas.toDataURL('image/png'); + } catch { + return null; + } + } +}