first commit

This commit is contained in:
root
2025-11-09 11:02:09 +01:00
commit 450b4dc402
25 changed files with 20788 additions and 0 deletions

9
.env Normal file
View File

@@ -0,0 +1,9 @@
# MQTT-Konfiguration
MQTT_BROKER=mqtt://iobroker:1884
MQTT_TOPIC=foodsharing/pickupCheck/config/meik
MQTT_USER=mqtt
MQTT_PASSWORD=mqtt!1884!
# Server-Konfiguration
PORT=3000
NODE_ENV=production

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

37
AGENTS.md Normal file
View File

@@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/` holds the React client: `index.js` bootstraps the app, `PickupConfigEditor.js` manages form logic, and sibling files (`App.js`, `*.css`, `*.test.js`) live beside their concerns for easy discovery.
- `public/` contains the static shell served during development; only add files that must be copied verbatim into the build output.
- `server.js` is the Express + MQTT bridge that serves the built client, exposes `/api/iobroker/pickup-config`, and persists data to `config/pickup-config.json`.
- `config/` stores generated runtime state. Keep it writable but untracked so local credentials never leak.
- `build/` is created by `npm run build` and shipped by the Express server or Docker image; never edit files here manually.
- `docker-compose.yml`, `Dockerfile`, and `rebuildContainer.sh` encapsulate deployment; update them when server ports, env vars, or base images change.
## Build, Test, and Development Commands
- `npm install` restore dependencies in `package.json`.
- `npm start` launch the CRA dev server on port 3000 with live reload.
- `npm run build` emit the production bundle into `build/`; run before `node server.js` or container builds.
- `npm test` run React Testing Library suites in watch mode; append `-- --watch=false` in CI.
- `node server.js` serve the prebuilt UI, REST API, and MQTT sync using values from `.env` (e.g., `MQTT_BROKER`, `MQTT_TOPIC`, `MQTT_USER`, `MQTT_PASSWORD`).
- `docker-compose up --build` rebuild and start the containerized service, syncing the bundled UI and server.
## Coding Style & Naming Conventions
- Use 2-space indentation and Standard/Prettier-compatible formatting; rely on the CRA ESLint config (`react-app`, `react-app/jest`) for feedback.
- Favor functional React components with PascalCase filenames (`PickupConfigEditor.js`) and camelCase props/state keys.
- Keep config schema fields (e.g., `desiredWeekday`, `onlyNotify`) camelCase across client, API payloads, and MQTT messages.
- Prefer descriptive folder-local CSS files rather than global selectors; co-locate assets next to their component whenever possible.
## Testing Guidelines
- React Testing Library + Jest underpin `App.test.js`; add `<Component>.test.js` files alongside components to exercise rendering, validation, and MQTT payload shaping.
- Initialize helpers inside `setupTests.js` to keep suites lean.
- Aim for meaningful edge cases (blank config, duplicate IDs, toggling `onlyNotify`). Pull requests should demonstrate passing `npm test` output or CI logs.
## Commit & Pull Request Guidelines
- Follow conventional commits (e.g., `feat: add mqtt auth fields`, `fix: debounce config saves`) and keep subjects ≤72 characters.
- Reference issues or MQTT topics impacted inside the body, and describe user-visible changes plus verification steps.
- PRs must include: summary of API/UI changes, screenshots or JSON samples when modifying config shape, notes on new env vars, and confirmation that `npm test` and `npm run build` succeed.
## Security & Configuration Tips
- Store secrets in `.env`, not in version control; provide `.env.example` updates when new variables (ports, credentials) are introduced.
- The server writes to `config/pickup-config.json`; ensure the directory exists and has the correct permissions before deploying or running containers.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:18-alpine
# Arbeitsverzeichnis im Container
WORKDIR /app
# Kopieren der package.json-Dateien für effizienteres Caching
COPY package*.json ./
# Installation von Abhängigkeiten
RUN npm install
# Kopieren des Quellcodes
COPY . .
# Build der React-App
RUN npm run build
# Freigegebener Port
EXPOSE 3000
# Starten des Node.js-Servers
CMD ["node", "server.js"]

73
config/pickup-config.json Normal file
View File

@@ -0,0 +1,73 @@
[
{
"id": "63448",
"active": false,
"checkProfileId": true,
"onlyNotify": true,
"label": "Penny Baden-Oos",
"desiredDate": null
},
{
"id": "44975",
"active": false,
"checkProfileId": false,
"onlyNotify": true,
"label": "Aldi Kuppenheim",
"desiredWeekday": null
},
{
"id": "44972",
"active": false,
"checkProfileId": false,
"onlyNotify": true,
"label": "Aldi Biblisweg",
"desiredWeekday": null
},
{
"id": "44975",
"active": false,
"checkProfileId": true,
"onlyNotify": false,
"label": "Aldi Kuppenheim",
"desiredDate": "2025-06-28"
},
{
"id": "33875",
"active": true,
"checkProfileId": true,
"onlyNotify": false,
"label": "Cap Markt",
"desiredWeekday": "Donnerstag",
"desiredDate": null
},
{
"id": "42322",
"active": false,
"checkProfileId": true,
"onlyNotify": true,
"label": "Edeka Haueneberstein",
"desiredDate": null
},
{
"id": "51450",
"active": false,
"checkProfileId": true,
"onlyNotify": true,
"label": "Hornbach Grünwinkel"
},
{
"id": "63367",
"label": "Arena Balkan Bäckerei",
"active": false,
"checkProfileId": true,
"onlyNotify": true
},
{
"id": "42264",
"label": "Bildungshaus St. Bernhard, Rastatt",
"active": false,
"checkProfileId": true,
"onlyNotify": false,
"desiredDate": null
}
]

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
pickup-config-app:
build: .
ports:
- "3005:3000"
env_file:
- .env # Lädt Umgebungsvariablen aus der .env-Datei
restart: unless-stopped
volumes:
- ./config:/app/config # Für persistente Konfiguration
networks:
- nginx-proxy-manager_default
labels:
- com.centurylinklabs.watchtower.enable=false
networks:
nginx-proxy-manager_default:
external: true # Verbindung zu einem bestehenden Netzwerk (optional)

19467
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "temp-react-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"mqtt": "^5.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-scripts": "^5.0.1"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

44
public/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Foodsharing Abholung-Konfiguration</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

8
rebuildContainer.sh Executable file
View File

@@ -0,0 +1,8 @@
#/bin/bash
# Bauen Sie die React-App neu
npm run build
# Erzwingen Sie einen Neubau des Docker-Images
docker compose down
docker compose build --no-cache
docker compose up -d

126
server.js Normal file
View File

@@ -0,0 +1,126 @@
const express = require('express');
const path = require('path');
const cors = require('cors');
const fs = require('fs');
const bodyParser = require('body-parser');
const mqtt = require('mqtt');
const app = express();
const port = process.env.PORT || 3000;
const mqttBroker = process.env.MQTT_BROKER || 'mqtt://192.168.1.100:1883';
const mqttTopic = process.env.MQTT_TOPIC || 'iobroker/pickupCheck/config';
const mqttUser = process.env.MQTT_USER || 'iobroker';
const mqttPassword = process.env.MQTT_PASSWORD || 'password';
const configPath = './config/pickup-config.json';
// Logger mit Timestamp
function logWithTimestamp(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}]`, ...args);
}
function errorWithTimestamp(...args) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}]`, ...args);
}
// MQTT-Client mit Authentifizierung initialisieren
const mqttClient = mqtt.connect(mqttBroker, {
clientId: 'pickup-config-web-' + Math.random().toString(16).substring(2, 8),
clean: true,
username: mqttUser,
password: mqttPassword
});
// MQTT-Events
mqttClient.on('connect', () => {
logWithTimestamp('Verbunden mit MQTT-Broker:', mqttBroker);
mqttClient.subscribe(mqttTopic, (err) => {
if (!err) {
logWithTimestamp('Abonniert auf Topic:', mqttTopic);
mqttClient.publish(mqttTopic + '/get', 'true');
}
});
});
mqttClient.on('error', (error) => {
errorWithTimestamp('MQTT-Fehler:', error);
});
mqttClient.on('message', (topic, message) => {
logWithTimestamp('Nachricht erhalten auf Topic:', topic);
if (topic === mqttTopic) {
try {
const config = JSON.parse(message.toString());
logWithTimestamp('Konfiguration vom MQTT-Broker erhalten');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
} catch (error) {
errorWithTimestamp('Fehler beim Verarbeiten der MQTT-Nachricht:', error);
}
}
});
// Middleware
app.use(cors());
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'build')));
// Sicherstellen, dass Konfigurationsordner existiert
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Initiale Konfigurationsdatei erstellen, falls nicht vorhanden
if (!fs.existsSync(configPath)) {
const initialConfig = [
{ id: "63448", active: false, checkProfileId: true, onlyNotify: true, label: "Penny Baden-Oos" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredWeekday: "Samstag" },
{ id: "44972", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Biblisweg", desiredWeekday: "Dienstag" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredDate: "2025-05-18" },
{ id: "33875", active: false, checkProfileId: true, onlyNotify: false, label: "Cap Markt", desiredWeekday: "Donnerstag" },
{ id: "42322", active: false, checkProfileId: false, onlyNotify: false, label: "Edeka Haueneberstein" },
{ id: "51450", active: false, checkProfileId: true, onlyNotify: false, label: "Hornbach Grünwinkel" }
];
fs.writeFileSync(configPath, JSON.stringify(initialConfig, null, 2));
mqttClient.publish(mqttTopic, JSON.stringify(initialConfig));
logWithTimestamp('Initiale Konfiguration erstellt und an MQTT gesendet');
}
// API: Konfiguration abrufen
app.get('/api/iobroker/pickup-config', (req, res) => {
try {
const configData = fs.readFileSync(configPath, 'utf8');
res.json(JSON.parse(configData));
} catch (error) {
errorWithTimestamp('Error reading configuration:', error);
res.status(500).json({ error: 'Failed to read configuration' });
}
});
// API: Konfiguration speichern
app.post('/api/iobroker/pickup-config', (req, res) => {
try {
const newConfig = req.body;
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
mqttClient.publish(mqttTopic, JSON.stringify(newConfig));
logWithTimestamp('Konfiguration über MQTT gesendet');
res.json({ success: true });
} catch (error) {
errorWithTimestamp('Error saving configuration:', error);
res.status(500).json({ error: 'Failed to save configuration' });
}
});
// React-App ausliefern
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
// Server starten
app.listen(port, () => {
logWithTimestamp(`Server läuft auf Port ${port}`);
});

119
src/App.css Normal file
View File

@@ -0,0 +1,119 @@
/* App.css - Zusätzliche Stilregeln für die Pickup-Konfigurations-App */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f7fa;
margin: 0;
padding: 0;
}
.App {
min-height: 100vh;
padding: 1rem;
}
/* Verbesserte Tabellen-Styles */
table {
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
th {
background-color: #f3f4f6;
font-weight: 600;
text-align: left;
padding: 12px 16px;
border-bottom: 2px solid #e5e7eb;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
tr:last-child td {
border-bottom: none;
}
/* Button-Styles */
button {
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
button:focus {
outline: none;
}
/* Verbesserte Input-Styles */
input[type="checkbox"] {
cursor: pointer;
}
select, input[type="date"] {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #d1d5db;
font-size: 14px;
transition: all 0.2s ease;
}
select:focus, input[type="date"]:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
outline: none;
}
/* Status-Banner */
.status-banner {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
position: relative;
}
.status-banner.success {
background-color: #d1fae5;
border: 1px solid #10b981;
color: #065f46;
}
.status-banner.error {
background-color: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
}
/* JSON-Vorschau */
pre {
background-color: #f3f4f6;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.5;
}
/* Responsive Design */
@media (max-width: 768px) {
table {
font-size: 14px;
}
th, td {
padding: 8px 12px;
}
.button-container {
flex-direction: column;
gap: 12px;
}
button {
width: 100%;
}
}

484
src/App.js Normal file
View File

@@ -0,0 +1,484 @@
import React, { useState, useEffect } from 'react';
import './App.css';
function App() {
const [config, setConfig] = useState([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [newEntry, setNewEntry] = useState({
id: "",
label: "",
active: false,
checkProfileId: true,
onlyNotify: false
});
const [showNewEntryForm, setShowNewEntryForm] = useState(false);
// API-URL für Server-Endpunkte
const API_URL = '/api/iobroker/pickup-config';
// Beim Laden der Komponente die aktuelle Konfiguration abrufen
useEffect(() => {
fetchConfig();
}, []);
// Konfiguration vom Server abrufen
const fetchConfig = async () => {
setLoading(true);
setError('');
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setConfig(data);
setLoading(false);
} catch (err) {
console.error("Fehler beim Laden der Konfiguration:", err);
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
setLoading(false);
// Fallback zur statischen Konfiguration bei Fehler
setConfig([
{ id: "63448", active: false, checkProfileId: true, onlyNotify: true, label: "Penny Baden-Oos" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredWeekday: "Samstag" },
{ id: "44972", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Biblisweg", desiredWeekday: "Dienstag" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredDate: "2025-05-18" },
{ id: "33875", active: false, checkProfileId: true, onlyNotify: false, label: "Cap Markt", desiredWeekday: "Donnerstag" },
{ id: "42322", active: false, checkProfileId: false, onlyNotify: false, label: "Edeka Haueneberstein" },
{ id: "51450", active: false, checkProfileId: true, onlyNotify: false, label: "Hornbach Grünwinkel" }
]);
}
};
// Konfiguration auf dem Server speichern
const saveConfig = async () => {
setStatus('Speichere...');
setError('');
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
setStatus('Konfiguration erfolgreich gespeichert!');
// Status nach 3 Sekunden wieder zurücksetzen
setTimeout(() => setStatus(''), 3000);
} else {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
} catch (err) {
console.error("Fehler beim Speichern:", err);
setError(`Fehler beim Speichern: ${err.message}`);
}
};
// Eintrag hinzufügen
const addEntry = () => {
// Validierung
if (!newEntry.id || !newEntry.label) {
setError('ID und Bezeichnung müssen ausgefüllt werden!');
return;
}
// Neuen Eintrag zur Konfiguration hinzufügen
const updatedConfig = [...config, newEntry];
setConfig(updatedConfig);
// Formular zurücksetzen
setNewEntry({
id: "",
label: "",
active: false,
checkProfileId: true,
onlyNotify: false
});
// Formular ausblenden
setShowNewEntryForm(false);
// Erfolgsmeldung anzeigen
setStatus('Neuer Eintrag hinzugefügt!');
setTimeout(() => setStatus(''), 3000);
};
// Eintrag löschen
const deleteEntry = (index) => {
if (window.confirm('Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?')) {
const updatedConfig = [...config];
updatedConfig.splice(index, 1);
setConfig(updatedConfig);
setStatus('Eintrag gelöscht!');
setTimeout(() => setStatus(''), 3000);
}
};
// Event-Handler für verschiedene Änderungen an der Konfiguration
const handleToggleActive = (index) => {
const newConfig = [...config];
newConfig[index].active = !newConfig[index].active;
setConfig(newConfig);
};
const handleToggleProfileCheck = (index) => {
const newConfig = [...config];
newConfig[index].checkProfileId = !newConfig[index].checkProfileId;
setConfig(newConfig);
};
const handleToggleOnlyNotify = (index) => {
const newConfig = [...config];
newConfig[index].onlyNotify = !newConfig[index].onlyNotify;
setConfig(newConfig);
};
const handleWeekdayChange = (index, value) => {
const newConfig = [...config];
newConfig[index].desiredWeekday = value || null;
// Wenn ein Wochentag gesetzt wird, entfernen wir das spezifische Datum
if (value && newConfig[index].desiredDate) {
delete newConfig[index].desiredDate;
}
setConfig(newConfig);
};
const handleDateChange = (index, value) => {
const newConfig = [...config];
newConfig[index].desiredDate = value || null;
// Wenn ein spezifisches Datum gesetzt wird, entfernen wir den Wochentag
if (value && newConfig[index].desiredWeekday) {
delete newConfig[index].desiredWeekday;
}
setConfig(newConfig);
};
// Neue Eintragsdaten verwalten
const handleNewEntryChange = (e) => {
const { name, value, type, checked } = e.target;
setNewEntry({
...newEntry,
[name]: type === 'checkbox' ? checked : value
});
};
// Lade-Indikator anzeigen, während die Daten geladen werden
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-100">
<div className="text-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-xl">Lade Konfiguration...</p>
</div>
</div>
);
}
// Definiere die Wochentage
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
// Hauptkomponente rendern
return (
<div className="p-4 max-w-6xl mx-auto bg-white shadow-lg rounded-lg mt-4">
<h1 className="text-2xl font-bold mb-6 text-center text-gray-800">Foodsharing Abholung-Konfiguration</h1>
{/* Externe Links */}
<div className="flex justify-center mb-4 space-x-4">
<a
href="https://foodsharing.de/?page=dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Foodsharing
</a>
<a
href="https://iobroker.srv.medeba-media.de/#tab-intro"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
ioBroker Admin
</a>
</div>
{/* Fehler- und Status-Meldungen */}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{error}</span>
<button
className="absolute top-0 bottom-0 right-0 px-4 py-3"
onClick={() => setError('')}
>
<span className="text-xl">&times;</span>
</button>
</div>
)}
{status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{status}</span>
</div>
)}
{/* Konfigurationstabelle */}
<div className="overflow-x-auto mb-6">
<table className="min-w-full bg-white border border-gray-200 rounded-lg overflow-hidden">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2 border-b">Aktiv</th>
<th className="px-4 py-2 border-b">Geschäft</th>
<th className="px-4 py-2 border-b">Profil prüfen</th>
<th className="px-4 py-2 border-b">Nur benachrichtigen</th>
<th className="px-4 py-2 border-b">Wochentag</th>
<th className="px-4 py-2 border-b">Spezifisches Datum</th>
<th className="px-4 py-2 border-b">Aktionen</th>
</tr>
</thead>
<tbody>
{config.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}>
<td className="px-4 py-2 border-b text-center">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={item.active || false}
onChange={() => handleToggleActive(index)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</label>
</td>
<td className="px-4 py-2 border-b">
<div>
<span className="font-medium">{item.label}</span>
<br />
<span className="text-sm text-gray-500">ID: {item.id}</span>
</div>
</td>
<td className="px-4 py-2 border-b text-center">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={item.checkProfileId || false}
onChange={() => handleToggleProfileCheck(index)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</label>
</td>
<td className="px-4 py-2 border-b text-center">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={item.onlyNotify || false}
onChange={() => handleToggleOnlyNotify(index)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</label>
</td>
<td className="px-4 py-2 border-b">
<select
value={item.desiredWeekday || ''}
onChange={(e) => handleWeekdayChange(index, e.target.value)}
className="border rounded p-2 w-full bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={item.desiredDate}
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>{day}</option>
))}
</select>
</td>
<td className="px-4 py-2 border-b">
<input
type="date"
value={item.desiredDate || ''}
onChange={(e) => handleDateChange(index, e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={item.desiredWeekday}
/>
</td>
<td className="px-4 py-2 border-b text-center">
<button
onClick={() => deleteEntry(index)}
className="bg-red-500 hover:bg-red-600 text-white rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-red-500"
title="Löschen"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Neuer Eintrag Formular */}
{showNewEntryForm ? (
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4">Neuen Eintrag hinzufügen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">ID *</label>
<input
type="text"
name="id"
value={newEntry.id}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung *</label>
<input
type="text"
name="label"
value={newEntry.label}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<div className="flex flex-wrap gap-6 mb-4">
<div className="flex items-center">
<input
type="checkbox"
name="active"
checked={newEntry.active}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<label className="text-sm font-medium text-gray-700">Aktiv</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="checkProfileId"
checked={newEntry.checkProfileId}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<label className="text-sm font-medium text-gray-700">Profil prüfen</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="onlyNotify"
checked={newEntry.onlyNotify}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<label className="text-sm font-medium text-gray-700">Nur benachrichtigen</label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Wochentag</label>
<select
name="desiredWeekday"
value={newEntry.desiredWeekday || ''}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>{day}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Spezifisches Datum</label>
<input
type="date"
name="desiredDate"
value={newEntry.desiredDate || ''}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={newEntry.desiredWeekday}
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={() => setShowNewEntryForm(false)}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Abbrechen
</button>
<button
onClick={addEntry}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Hinzufügen
</button>
</div>
</div>
) : (
<button
onClick={() => setShowNewEntryForm(true)}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded mb-6 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Neuen Eintrag hinzufügen
</button>
)}
{/* Aktionsbuttons */}
<div className="flex justify-between mb-6">
<button
onClick={fetchConfig}
className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
>
Aktualisieren
</button>
<button
onClick={saveConfig}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
Konfiguration speichern
</button>
</div>
{/* JSON Vorschau
<div className="mt-8 p-4 border rounded bg-gray-50">
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto text-sm">
{JSON.stringify(config, null, 2)}
</pre>
</div> */}
</div>
);
}
export default App;

8
src/App.test.js Normal file
View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

229
src/PickupConfigEditor.js Normal file
View File

@@ -0,0 +1,229 @@
import { useState, useEffect } from 'react';
const PickupConfigEditor = () => {
const [config, setConfig] = useState([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
// Simulierte API-Endpunkte - diese müssen in Ihrer tatsächlichen Implementierung angepasst werden
const API_URL = '/api/iobroker/pickup-config';
useEffect(() => {
// Beim Laden der Komponente die aktuelle Konfiguration abrufen
fetchConfig();
}, []);
const fetchConfig = async () => {
setLoading(true);
setError('');
try {
// In einer echten Implementierung würden Sie Ihre API aufrufen
// Hier wird die statische Konfiguration verwendet
// const response = await fetch(API_URL);
// const data = await response.json();
// Simulierte Verzögerung und Antwort mit der statischen Konfiguration
setTimeout(() => {
const staticConfig = [
{ id: "63448", active: false, checkProfileId: true, onlyNotify: true, label: "Penny Baden-Oos" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredWeekday: "Samstag" },
{ id: "44972", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Biblisweg", desiredWeekday: "Dienstag" },
{ id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredDate: "2025-05-18" },
{ id: "33875", active: false, checkProfileId: true, onlyNotify: false, label: "Cap Markt", desiredWeekday: "Donnerstag" },
{ id: "42322", active: false, checkProfileId: false, onlyNotify: false, label: "Edeka Haueneberstein" },
{ id: "51450", active: false, checkProfileId: true, onlyNotify: false, label: "Hornbach Grünwinkel" }
];
setConfig(staticConfig);
setLoading(false);
}, 500);
} catch (err) {
setError('Fehler beim Laden der Konfiguration: ' + err.message);
setLoading(false);
}
};
const saveConfig = async () => {
setStatus('Speichere...');
setError('');
try {
// API-Aufruf zum Speichern der Konfiguration in ioBroker
// In einer echten Implementierung würden Sie Ihre API aufrufen
// const response = await fetch(API_URL, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(config),
// });
// Simulierte Verzögerung zum Darstellen des Speichervorgangs
setTimeout(() => {
setStatus('Konfiguration erfolgreich gespeichert!');
setTimeout(() => setStatus(''), 3000);
}, 1000);
} catch (err) {
setError('Fehler beim Speichern: ' + err.message);
}
};
const handleToggleActive = (index) => {
const newConfig = [...config];
newConfig[index].active = !newConfig[index].active;
setConfig(newConfig);
};
const handleToggleProfileCheck = (index) => {
const newConfig = [...config];
newConfig[index].checkProfileId = !newConfig[index].checkProfileId;
setConfig(newConfig);
};
const handleToggleOnlyNotify = (index) => {
const newConfig = [...config];
newConfig[index].onlyNotify = !newConfig[index].onlyNotify;
setConfig(newConfig);
};
const handleWeekdayChange = (index, value) => {
const newConfig = [...config];
newConfig[index].desiredWeekday = value;
// Wenn ein Wochentag gesetzt wird, entfernen wir das spezifische Datum
if (newConfig[index].desiredDate) {
delete newConfig[index].desiredDate;
}
setConfig(newConfig);
};
const handleDateChange = (index, value) => {
const newConfig = [...config];
newConfig[index].desiredDate = value;
// Wenn ein spezifisches Datum gesetzt wird, entfernen wir den Wochentag
if (newConfig[index].desiredWeekday) {
delete newConfig[index].desiredWeekday;
}
setConfig(newConfig);
};
if (loading) {
return <div className="text-center p-8">Lade Konfiguration...</div>;
}
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
return (
<div className="p-4 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">ioBroker Abholung-Konfiguration</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{status}
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2">Aktiv</th>
<th className="px-4 py-2">Geschäft</th>
<th className="px-4 py-2">Profil prüfen</th>
<th className="px-4 py-2">Nur benachrichtigen</th>
<th className="px-4 py-2">Wochentag</th>
<th className="px-4 py-2">Spezifisches Datum</th>
</tr>
</thead>
<tbody>
{config.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.active}
onChange={() => handleToggleActive(index)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<span className="font-medium">{item.label}</span>
<br />
<span className="text-sm text-gray-500">ID: {item.id}</span>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.checkProfileId}
onChange={() => handleToggleProfileCheck(index)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.onlyNotify}
onChange={() => handleToggleOnlyNotify(index)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<select
value={item.desiredWeekday || ''}
onChange={(e) => handleWeekdayChange(index, e.target.value)}
className="border rounded p-1 w-full"
disabled={item.desiredDate}
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>{day}</option>
))}
</select>
</td>
<td className="px-4 py-2">
<input
type="date"
value={item.desiredDate || ''}
onChange={(e) => handleDateChange(index, e.target.value)}
className="border rounded p-1 w-full"
disabled={item.desiredWeekday}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-between">
<button
onClick={fetchConfig}
className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded"
>
Zurücksetzen
</button>
<button
onClick={saveConfig}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
>
In ioBroker speichern
</button>
</div>
<div className="mt-8 p-4 border rounded bg-gray-50">
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto">
{JSON.stringify(config, null, 2)}
</pre>
</div>
</div>
);
};
export default PickupConfigEditor;

13
src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

13
src/reportWebVitals.js Normal file
View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
src/setupTests.js Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';