add: webseite mit jwt
BIN
webseite-react-php-jwt.zip
Normal file
8
webseite-react-php-jwt/react-app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeForeground": "#333",
|
||||
"titleBar.activeBackground": "#86cd8b",
|
||||
"titleBar.inactiveForeground": "#ddd",
|
||||
"titleBar.inactiveBackground": "#6fa973"
|
||||
}
|
||||
}
|
||||
377
webseite-react-php-jwt/react-app/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Installation der Pakete
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
npm install react-router-dom bootstrap uuid axios react-icons
|
||||
```
|
||||
|
||||
## DevDependencies
|
||||
|
||||
```bash
|
||||
npm install sass -D
|
||||
```
|
||||
|
||||
## Configuration von Vite
|
||||
|
||||
```js
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
quietDeps: true, // Unix
|
||||
silenceDeprecations: [
|
||||
'color-functions',
|
||||
'global-builtin',
|
||||
'import',
|
||||
'legacy-js-api',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
|
||||
# Products Store mit useContext und useReducer
|
||||
|
||||
`TableProducts` hält den Tabellen-State nicht mehr lokal in der Komponente. Stattdessen gibt es einen ausgelagerten Store aus **Context + Reducer**. So können Kind-Komponenten wie `RowProduct` den State lesen und Aktionen auslösen, ohne Callbacks per Props durchzureichen (kein Prop Drilling).
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
src/stores/products/
|
||||
initialState.js // Startzustand (Factory)
|
||||
reducer.js // reine State-Übergänge
|
||||
ProductsStore.jsx // Context, Provider, Custom Hooks
|
||||
index.js // öffentliche Exports
|
||||
```
|
||||
|
||||
## Step 1: `initialState` auslagern
|
||||
|
||||
Datei: `src/stores/products/initialState.js`
|
||||
|
||||
Der Startzustand wird als **Factory-Funktion** geschrieben, nicht als festes Objekt. So kann der Provider die geladenen Produkte (`items`) übergeben, und Validierung bleibt an einer Stelle.
|
||||
|
||||
```js
|
||||
export const createInitialState = (items) => {
|
||||
return { items: items || [] };
|
||||
};
|
||||
```
|
||||
|
||||
Später wird sie so verwendet:
|
||||
|
||||
```js
|
||||
useReducer(productsReducer, items, createInitialState);
|
||||
```
|
||||
|
||||
React ruft `createInitialState(items)` einmal beim ersten Render auf (lazy init). Das dritte Argument von `useReducer` ist genau dafür da.
|
||||
|
||||
## Step 2: Reducer auslagern
|
||||
|
||||
Datei: `src/stores/products/reducer.js`
|
||||
|
||||
Der Reducer bleibt eine **reine Funktion**: `(state, action) => newState`. Keine Side Effects außer einem `console.warn` für unbekannte Actions.
|
||||
|
||||
Action-Typen liegen in einem Objekt `PRODUCTS_ACTIONS`. Tippfehler fallen so früher auf als bei losen Strings wie `'UPDATE_AMMOUNT'`.
|
||||
|
||||
```js
|
||||
export const PRODUCTS_ACTIONS = {
|
||||
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
|
||||
UPDATE_PRICE: 'UPDATE_PRICE',
|
||||
};
|
||||
```
|
||||
|
||||
Jede Action folgt dem Muster `{ type, payload }`. Der Reducer kopiert den State (`...state`) und ersetzt nur das betroffene Item über `map`. Unveränderte Items behalten dieselbe Referenz – das ist wichtig für `memo(RowProduct)`.
|
||||
|
||||
## Step 3: Context aufteilen (State und Dispatch)
|
||||
|
||||
Datei: `src/stores/products/ProductsStore.jsx`
|
||||
|
||||
Best Practice: **zwei Contexts**, nicht einen.
|
||||
|
||||
| Context | Inhalt | Wann re-rendern Kind-Komponenten? |
|
||||
| ------------------------- | ----------- | ------------------------------------- |
|
||||
| `ProductsStateContext` | `{ items }` | bei jeder State-Änderung |
|
||||
| `ProductsDispatchContext` | `dispatch` | praktisch nie (`dispatch` ist stabil) |
|
||||
|
||||
`RowProduct` braucht nur Aktionen (also `dispatch`). Würde State und Dispatch in einem Value `{ state, dispatch }` stecken, bekäme jede Zeile bei jeder Eingabe ein neues Objekt und würde neu rendern – `memo` wäre wirkungslos.
|
||||
|
||||
```js
|
||||
const ProductsStateContext = createContext(null);
|
||||
const ProductsDispatchContext = createContext(null);
|
||||
```
|
||||
|
||||
`null` als Default ist Absicht: so erkennt der Custom Hook, wenn jemand den Store **außerhalb** des Providers nutzt.
|
||||
|
||||
## Step 4: Provider mit `useReducer` bauen
|
||||
|
||||
`ProductsProvider` ist die einzige Stelle, an der `useReducer` läuft. Er bekommt `items` (z. B. aus dem Fetch auf der Page) und reicht State sowie `dispatch` nach unten.
|
||||
|
||||
```jsx
|
||||
export const ProductsProvider = (props) => {
|
||||
const { children, items = [] } = props;
|
||||
const [state, dispatch] = useReducer(
|
||||
productsReducer,
|
||||
items,
|
||||
createInitialState,
|
||||
);
|
||||
|
||||
return (
|
||||
<ProductsStateContext.Provider value={state}>
|
||||
<ProductsDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</ProductsDispatchContext.Provider>
|
||||
</ProductsStateContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Wichtig: Die Komponente, die `useContext` aufruft, muss ein **Kind** des Providers sein – nicht dieselbe Komponente, die den Provider rendert. Deshalb gibt es `TableProducts` (Provider) und `TableProductsView` (Consumer).
|
||||
|
||||
## Step 5: Custom Hooks statt nacktem `useContext`
|
||||
|
||||
Drei Hooks kapseln den Zugriff und werfen einen Fehler, wenn der Provider fehlt:
|
||||
|
||||
1. `useProductsState()` – liest `{ items }`
|
||||
2. `useProductsDispatch()` – liefert `dispatch`
|
||||
3. `useProductsActions()` – fertige Callbacks `updateAmount` / `updatePrice`
|
||||
|
||||
Komponenten sollen nicht `dispatch({ type: '…' })` selbst zusammenbauen. Die Action-Objekte gehören in den Store, analog zu Redux Action Creators.
|
||||
|
||||
```js
|
||||
const updateAmount = useCallback(
|
||||
(amount, id) => {
|
||||
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_AMOUNT, payload: { id, amount } });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
```
|
||||
|
||||
`useCallback` + stabiles `dispatch` hält die Funktionsreferenz gleich. Zusammen mit `memo(RowProduct)` rendern nur Zeilen neu, deren Item-Props sich geändert haben.
|
||||
|
||||
## Step 6: Barrel-Export
|
||||
|
||||
Datei: `src/stores/products/index.js`
|
||||
|
||||
Andere Dateien importieren nur den Ordner, nicht interne Pfade:
|
||||
|
||||
```js
|
||||
import {
|
||||
ProductsProvider,
|
||||
useProductsState,
|
||||
useProductsActions,
|
||||
} from '../../stores/products';
|
||||
```
|
||||
|
||||
Reducer und `initialState` bleiben intern austauschbar, ohne dass `TableProducts` oder `RowProduct` ihre Imports ändern müssen.
|
||||
|
||||
## Step 7: `TableProducts` an den Store anbinden
|
||||
|
||||
`TableProducts` wrappt die Ansicht mit dem Provider und übergibt die Props als Initial-Items:
|
||||
|
||||
```jsx
|
||||
const TableProducts = (props) => {
|
||||
return (
|
||||
<ProductsProvider items={props.items}>
|
||||
<TableProductsView />
|
||||
</ProductsProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
`TableProductsView` liest den State und berechnet den Gesamtpreis lokal mit `useMemo`. Der Gesamtpreis ist **abgeleiteter State** – er gehört nicht in den Reducer, sondern wird aus `items` berechnet.
|
||||
|
||||
Die Handler `onHandleAmount` / `onHandlePrice` entfallen. `RowProduct` holt sie selbst aus dem Store.
|
||||
|
||||
## Step 8: Prop Drilling in `RowProduct` entfernen
|
||||
|
||||
Vorher: `TableProducts` → `RowProduct` → Inputs (Callbacks als Props).
|
||||
|
||||
Nachher: `RowProduct` ruft `useProductsActions()` auf. Die Zeile bekommt nur noch die Produktdaten als Props (`_id`, `title`, `stock`, `price`, `sku`). Die `_id` bindet den Input-Wert an das richtige Item:
|
||||
|
||||
```js
|
||||
const { updateAmount, updatePrice } = useProductsActions();
|
||||
|
||||
const handleAmount = useCallback(
|
||||
(value) => {
|
||||
updateAmount(value, _id);
|
||||
},
|
||||
[updateAmount, _id],
|
||||
);
|
||||
```
|
||||
|
||||
Die Produktdaten bleiben Props (nicht aus dem Context gelesen). Würde jede Zeile `items` aus dem State-Context lesen, würden **alle** Zeilen bei jeder Änderung neu rendern.
|
||||
|
||||
## Datenfluss
|
||||
|
||||
```
|
||||
TableProductsPage fetch JSON → items
|
||||
│
|
||||
▼
|
||||
TableProducts ProductsProvider(items)
|
||||
│
|
||||
├── TableProductsView useProductsState() → Tabelle + Total
|
||||
│
|
||||
└── RowProduct useProductsActions() → UPDATE_AMOUNT / UPDATE_PRICE
|
||||
│
|
||||
▼
|
||||
productsReducer neuer items-Array → Context → UI
|
||||
```
|
||||
|
||||
## Kurz: warum diese Aufteilung?
|
||||
|
||||
- **`initialState.js`** – Startzustand und Validierung, unabhängig von React
|
||||
- **`reducer.js`** – alle State-Übergänge testbar ohne Komponenten
|
||||
- **`ProductsStore.jsx`** – React-Anbindung (Context, Provider, Hooks)
|
||||
- **zwei Contexts** – Zeilen, die nur dispatchen, re-rendern nicht unnötig
|
||||
- **Custom Hooks** – klare API, Fehler wenn der Provider fehlt
|
||||
- **kein Prop Drilling** – neue Kind-Komponenten können den Store direkt nutzen
|
||||
|
||||
---
|
||||
|
||||
# Architektur & Funktionsweise des ProductsProvider
|
||||
|
||||
Dieser Store implementiert ein **Optimistic UI mit entkoppeltem, gepuffertem Backend-Sync (Debounce & Merge)**.
|
||||
|
||||
```
|
||||
UI Input (z. B. Tippen im Input-Feld)
|
||||
│
|
||||
├──▶ 1. dispatch(...) ──▶ State aktualisiert sich SOFORT (UI reagiert flüssig)
|
||||
│
|
||||
└──▶ 2. schedulePatch(...) ──▶ Änderungen sammeln & Timer starten (400 ms)
|
||||
│
|
||||
(nach 400 ms Ruhe)
|
||||
│
|
||||
▼
|
||||
flushPatch(...) ──▶ HTTP PATCH an API
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Kernkonzept: `schedulePatch` vs. `flushPatch`
|
||||
|
||||
Beim Bearbeiten von Feldern (wie Preis oder Menge) feuert die Eingabe bei jedem Tastendruck (`onChange`). Würde man jedes Mal sofort einen API-Request senden, würde das Backend mit HTTP-Aufrufen überflutet werden.
|
||||
|
||||
### `schedulePatch(id, patch)` – Das Sammelbecken & der Timer
|
||||
|
||||
**Zweck:** Nimmt Änderungen entgegen, führt sie pro Produkt zusammen (_mergen_) und setzt den Countdown zurück (_debouncen_).
|
||||
|
||||
1. **Merge (`pendingPatches`):**
|
||||
|
||||
- Tippt der Nutzer schnell hintereinander Menge `5` und Preis `19.99`, merkt sich die Map: `{ stock: 5, price: 19.99 }`.
|
||||
|
||||
2. **Timer-Reset (`persistTimers`):**
|
||||
|
||||
- Läuft bereits ein Timer für dieses Produkt, wird er mit `clearTimeout` gestoppt.
|
||||
- Ein neuer 400ms-Timer startet. Erst wenn der Nutzer **400 ms lang nichts mehr tippt**, läuft der Timer ab.
|
||||
|
||||
---
|
||||
|
||||
### `flushPatch(id)` – Der Ausführer
|
||||
|
||||
**Zweck:** Holt die gesammelten Änderungen ab und sendet den eigentlichen API-Request.
|
||||
|
||||
1. **Puffer leeren:** Holt das zusammengefasste Patch-Objekt aus `pendingPatches.current` und löscht es sofort aus der Map (verhindert doppelte Requests).
|
||||
2. **Timer aufräumen:** Entfernt eventuell noch vorhandene Timer-Referenzen.
|
||||
3. **API-Call (`patchProduct`):** Sendet den minimalen Patch an den Server.
|
||||
4. **Erfolg:** Aktualisiert das Produkt im State mit den offiziellen Serverdaten (`SET_ITEM`).
|
||||
5. **Fehlerfall (Rollback):** Schlägt der Request fehl, wird der Fehler gesetzt und via `loadProducts()` der alte/gültige Serverzustand nachgeladen (Rollback des optimistischen UI-Updates).
|
||||
|
||||
---
|
||||
|
||||
## 2. Der Unmount-Cleanup (`useEffect`)
|
||||
|
||||
Verlässt der Nutzer die Seite oder wird der Provider abgebaut, greift die Cleanup-Funktion:
|
||||
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 1. Laufende Timer sofort stoppen
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
|
||||
// 2. Ungespeicherte Änderungen sofort absenden ("Flush on Unmount")
|
||||
patches.forEach((patch, id) => {
|
||||
if (patch && Object.keys(patch).length > 0) {
|
||||
patchProduct(id, patch).catch(() => {});
|
||||
}
|
||||
});
|
||||
patches.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
- Verhindert Datenverlust, wenn der Nutzer kurz nach einer Eingabe navigiert.
|
||||
|
||||
---
|
||||
|
||||
## 3. Die 3-Context-Architektur
|
||||
|
||||
Der Store teilt die Verantwortlichkeiten in **drei separate Contexte** auf, um unnötige Re-Renders im Komponentenbaum vollständig zu unterbinden:
|
||||
|
||||
| Context | Hook | Inhalt | Re-Render Verhalten |
|
||||
| ----------------------------- | ----------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **`ProductsStateContext`** | `useProductsState()` | `state` (Items, Loading, Error) | Rendert **nur** neu, wenn sich der State ändert (z. B. Listen-Komponenten). |
|
||||
| **`ProductsDispatchContext`** | `useProductsDispatch()` | `dispatch` | **Rendert nie neu** (Referenz bleibt über gesamten App-Lebenszyklus stabil). |
|
||||
| **`ProductsActionsContext`** | `useProductsActions()` | Methoden (`updatePrice`, `addProduct`, etc.) | **Rendert nie neu**, da alle Actions mit `useCallback` und stabilen Abhängigkeiten memoisiert sind. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Übersicht der Action-Methoden
|
||||
|
||||
- **`updateAmount(amount, id)` & `updatePrice(price, id)`:**
|
||||
- **Optimistisch & Debounced:** Ändern sofort den lokalen State via `dispatch` und reichen die Daten an `schedulePatch` weiter.
|
||||
|
||||
- **`addProduct(product)`, `saveProduct(id, product)`, `removeProduct(id)`:**
|
||||
- **Pessimistisch & Direkt:** Warten direkt auf die API-Antwort (`async/await`) und updaten erst nach erfolgreichem Server-Response den Reducer-State.
|
||||
|
||||
- **`loadProducts()`:**
|
||||
- Lädt die komplette Produktliste manuell neu (z. B. nach einem Rollback oder Pull-to-Refresh).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## useLayoutEffect vs. useEffect
|
||||
|
||||
Der Hauptunterschied liegt im **Timing der Ausführung** relativ zum Browser-Rendering:
|
||||
|
||||
| Eigenschaft | `useLayoutEffect` | `useEffect` |
|
||||
| --- | --- | --- |
|
||||
| **Ausführungsart** | **Synchron** (blockiert das Painting) | **Asynchron / Deferiert** (nach dem Painting) |
|
||||
| **Zeitpunkt** | Direkt nach den DOM-Mutationen, *bevor* der Browser den Bildschirm aktualisiert | Nachdem der Browser die Pixel auf den Bildschirm gezeichnet hat |
|
||||
| **Haupt-Use-Case** | DOM-Messungen (`getBoundingClientRect`), visuelle DOM-Mutationen zur Vermeidung von Layout Shifts / Flackern | Data Fetching, Subscriptions, Logging, Storage-Zugriffe |
|
||||
| **Performance-Impact** | Kann UI-Ruckler verursachen, wenn Berechnungen zu lange dauern | Keine Blockierung der UI-Darstellung |
|
||||
|
||||
---
|
||||
|
||||
**Analyse deines Codes:**
|
||||
|
||||
* **`useLayoutEffect` für `setAuthToken(state.token)**`:
|
||||
* *Zweck hier:* Der Token (z. B. in Axios-Headern oder API-Clients) wird **synchron** gesetzt, noch bevor Kindkomponenten in ihren eigenen `useEffect`-Hooks initiale API-Aufrufe ausführen. Dadurch wird ein Race Condition vermieden, bei dem ein Kind-Effekt einen Request ohne gültigen Header losschickt.
|
||||
|
||||
|
||||
* **`useEffect` für `persistAuth(...)**`:
|
||||
* *Zweck hier:* Das Schreiben in den `localStorage`/`IndexedDB` ist ein Side Effect, der die visuelle Darstellung nicht direkt beeinflusst und daher die UI nicht blockieren muss.
|
||||
29
webseite-react-php-jwt/react-app/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
webseite-react-php-jwt/react-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>react-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3196
webseite-react-php-jwt/react-app/package-lock.json
generated
Normal file
34
webseite-react-php-jwt/react-app/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "react-app",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"axios": "^1.20.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"uuid": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.5",
|
||||
"globals": "^17.11.0",
|
||||
"sass": "^1.103.1",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
[
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd92",
|
||||
"title": "Smart Coffee Mug with LCD Level Indicator",
|
||||
"sku": "MUG0007",
|
||||
"stock": 25,
|
||||
"price": 49.99,
|
||||
"description": "Know exactly how much coffee you have left with this LCD-equipped mug.",
|
||||
"tagline": "Never face an empty mug again."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd93",
|
||||
"title": "Bluetooth Coffee Mug with Fortune Telling Scanner",
|
||||
"sku": "MUG0013",
|
||||
"stock": 15,
|
||||
"price": 99.99,
|
||||
"description": "Get your daily fortune with every sip using this Bluetooth-connected mug.",
|
||||
"tagline": "Coffee reading, made smart."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd94",
|
||||
"title": "South Pole Tested Pre-Warmed Ink Pen",
|
||||
"sku": "OFF3145",
|
||||
"stock": 34,
|
||||
"price": 29.95,
|
||||
"description": "Write smoothly even in the coldest conditions with this pre-warmed ink pen.",
|
||||
"tagline": "The pen that conquers the cold."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd95",
|
||||
"title": "Ambidextrous Computer Mouse",
|
||||
"sku": "COM1001",
|
||||
"stock": 50,
|
||||
"price": 19.9,
|
||||
"description": "Finally, a mouse designed for both left and right-handed users.",
|
||||
"tagline": "End the left-right struggle."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd96",
|
||||
"title": "Easter Egg Themed Webcam",
|
||||
"sku": "COM0404",
|
||||
"stock": 8,
|
||||
"price": 14.99,
|
||||
"description": "Add some festive flair to your video calls with this Easter egg webcam.",
|
||||
"tagline": "Happy Easter, every day."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd97",
|
||||
"title": "Vulcan Language vi Cheatsheet",
|
||||
"sku": "COM0001",
|
||||
"stock": 6,
|
||||
"price": 9.9,
|
||||
"description": "Master the Vulcan language and vi editor simultaneously with this handy cheatsheet.",
|
||||
"tagline": "Learn vi and Vulcan, live long and prosper."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cd98",
|
||||
"title": "Klingon Language emacs Cheatsheet",
|
||||
"sku": "COM1536",
|
||||
"stock": 33,
|
||||
"price": 9.9,
|
||||
"description": "Conquer the Klingon language and emacs with this comprehensive cheatsheet.",
|
||||
"tagline": "Learn emacs and Klingon, qapla'!"
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda0",
|
||||
"title": "Self-Folding Laundry Basket",
|
||||
"sku": "HME0023",
|
||||
"stock": 15,
|
||||
"price": 79.99,
|
||||
"description": "Say goodbye to laundry clutter with this self-folding basket.",
|
||||
"tagline": "Laundry day just got easier."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda1",
|
||||
"title": "Noise-Cancelling Headphones for Cats",
|
||||
"sku": "PET0112",
|
||||
"stock": 39,
|
||||
"price": 39.95,
|
||||
"description": "Give your cat the gift of silence with these noise-canceling headphones.",
|
||||
"tagline": "Purrfect tranquility for your feline friend."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda2",
|
||||
"title": "Glow-in-the-Dark Toilet Paper",
|
||||
"sku": "HME0221",
|
||||
"stock": 65,
|
||||
"price": 12.5,
|
||||
"description": "Navigate your midnight bathroom trips with ease using this glow-in-the-dark toilet paper.",
|
||||
"tagline": "A guiding light in the darkness."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda3",
|
||||
"title": "Automatic Plant Waterer with Compliment Dispenser",
|
||||
"sku": "GRD0334",
|
||||
"stock": 17,
|
||||
"price": 69.99,
|
||||
"description": "Keep your plants happy and hydrated with automatic watering and daily compliments.",
|
||||
"tagline": "Nurture your plants, boost their self-esteem."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda4",
|
||||
"title": "Self-Stirring Cereal Bowl",
|
||||
"sku": "KIT0445",
|
||||
"stock": 55,
|
||||
"price": 24.95,
|
||||
"description": "Enjoy perfectly crunchy cereal every time with this self-stirring bowl.",
|
||||
"tagline": "No more soggy surprises."
|
||||
},
|
||||
{
|
||||
"_id": "675bee96bb39023b77e6cda5",
|
||||
"title": "Polygon Planet Lamp",
|
||||
"sku": "LMP0556",
|
||||
"stock": 4,
|
||||
"price": 39.9,
|
||||
"description": "Transform your space with the enchanting glow of a planetarium-inspired lamp.",
|
||||
"tagline": "Bring the cosmos into your home."
|
||||
}
|
||||
]
|
||||
BIN
webseite-react-php-jwt/react-app/public/img/header-bg.jpg
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
webseite-react-php-jwt/react-app/public/img/jokes-bg.jpg
Normal file
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
1923
webseite-react-php-jwt/react-app/public/img/logo-react.svg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
webseite-react-php-jwt/react-app/public/img/slides/slide-01.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
webseite-react-php-jwt/react-app/public/img/slides/slide-02.jpg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
webseite-react-php-jwt/react-app/public/img/slides/slide-03.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
1
webseite-react-php-jwt/react-app/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
20
webseite-react-php-jwt/react-app/src/App.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import FooterMain from './components/footers/FooterMain';
|
||||
import HeaderMain from './components/headers/HeaderMain';
|
||||
import NavMain from './components/navs/NavMain';
|
||||
import Router from './routes/Router';
|
||||
import { AuthProvider } from './stores/auth';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<HeaderMain />
|
||||
<NavMain />
|
||||
<main>
|
||||
<Router />
|
||||
</main>
|
||||
<FooterMain />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 148.14 90.61"><defs><linearGradient id="a" x1="76.09" x2="76.09" y1="9.78" y2="56.04" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#65c1c3"/><stop offset="1" stop-color="#569ed6"/></linearGradient><linearGradient id="b" x1="239.45" x2="220.4" y1="312.2" y2="331.38" gradientTransform="matrix(1 0 0 -1 -187.36 360.99)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5784c3"/><stop offset=".2" stop-color="#5480c0"/><stop offset=".39" stop-color="#4c74b8"/><stop offset=".58" stop-color="#3f60aa"/><stop offset=".67" stop-color="#3856a3"/></linearGradient><linearGradient xlink:href="#a" id="c" x1="228.06" x2="228.06" y1="319.94" y2="351.23" gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"/><linearGradient xlink:href="#b" id="d" x1="-129.62" x2="-148.68" y1="17.01" y2="36.19" gradientTransform="matrix(-1 0 0 1 -33.63 0)"/><linearGradient xlink:href="#a" id="e" x1="-141.01" x2="-141.01" y1="24.75" y2="56.04" gradientTransform="matrix(-1 0 0 1 -33.63 0)"/></defs><path fill="url(#a)" d="M73.98 56.04H60.7L78.21 9.78h13.28z"/><path d="M15.96 66.6h2.13l.98 10.8h.04l1.04-10.8h2.41l1.04 10.8h.04l.98-10.8h1.91L25.1 80.65h-2.75l-1-9.48h-.04l-1 9.48h-2.93L15.95 66.6ZM27.66 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM34.95 66.6h3.33c2.29 0 3.27 1.06 3.27 3.23v.56c0 1.45-.44 2.35-1.43 2.75v.04c1.18.4 1.65 1.44 1.65 2.93v1.21c0 2.17-1.14 3.33-3.35 3.33h-3.47zm3.07 5.72c.82 0 1.33-.36 1.33-1.49v-.78c0-1-.34-1.45-1.12-1.45h-1.06v3.71h.86Zm.41 6.32c.74 0 1.14-.34 1.14-1.39v-1.22c0-1.31-.42-1.71-1.43-1.71h-.98v4.32h1.26ZM46.28 66.6h3.37c2.21 0 3.29 1.22 3.29 3.47v7.11c0 2.25-1.08 3.47-3.29 3.47h-3.37zm3.33 12.04c.7 0 1.12-.36 1.12-1.37v-7.31c0-1-.42-1.37-1.12-1.37h-1.12v10.04h1.12ZM54.43 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM61.13 66.6h2.23l1.45 10.9h.04l1.45-10.9h2.03L66.2 80.65h-2.93L61.14 66.6ZM69.38 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM76.67 66.6h2.21v12.05h3.63v2.01h-5.84V66.61ZM83.34 77.32v-7.39c0-2.25 1.18-3.53 3.35-3.53s3.35 1.28 3.35 3.53v7.39c0 2.25-1.18 3.53-3.35 3.53s-3.35-1.28-3.35-3.53m4.49.14v-7.67c0-1-.44-1.39-1.14-1.39s-1.14.38-1.14 1.39v7.67c0 1 .44 1.39 1.14 1.39s1.14-.38 1.14-1.39M91.53 66.6h3.25c2.21 0 3.29 1.22 3.29 3.47v1.39c0 2.25-1.08 3.47-3.29 3.47h-1.04v5.72h-2.21zm3.25 6.32c.7 0 1.08-.32 1.08-1.32v-1.67c0-1-.38-1.33-1.08-1.33h-1.04v4.32zM99.28 66.6h3.15l1.41 10.06h.04l1.41-10.06h3.15v14.05h-2.09V70.01h-.04l-1.61 10.64h-1.85l-1.61-10.64h-.04v10.64h-1.93V66.6ZM110.08 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM117.36 66.6h2.77l2.15 8.41h.04V66.6h1.97v14.05h-2.27l-2.65-10.26h-.04v10.26h-1.97zM127.66 68.61h-2.31V66.6h6.83v2.01h-2.31v12.05h-2.21z"/><path fill="url(#b)" d="M51.27 56.03V39.76L36.26 24.75l-4.65 4.65a4.943 4.943 0 0 0 0 6.99l19.64 19.64.02-.02Z"/><path fill="url(#c)" d="M51.26 26.05V9.78l-.02-.02-19.67 19.67a4.913 4.913 0 0 0 0 6.95l4.68 4.68 8.13-8.13z"/><path fill="url(#d)" d="M96.81 9.78v16.27l15.01 15.01 4.65-4.65a4.943 4.943 0 0 0 0-6.99L96.83 9.78l-.02.02Z"/><path fill="url(#e)" d="M96.82 39.76v16.27l.02.02 19.67-19.67a4.913 4.913 0 0 0 0-6.95l-4.68-4.68-8.13 8.13z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
webseite-react-php-jwt/react-app/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,48 @@
|
||||
import NavSocial from '../navs/NavSocial';
|
||||
|
||||
const FooterMain = (props) => {
|
||||
// const { } = props;
|
||||
|
||||
return (
|
||||
<footer className="m-footer-main">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-4">
|
||||
<div className="box">
|
||||
<h2>Contact</h2>
|
||||
<address>
|
||||
John Smith
|
||||
<br />
|
||||
1 Example Street
|
||||
<br />
|
||||
Anytown, AB1 2CD
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<div className="box">
|
||||
<h2>More Information</h2>
|
||||
<nav className="nav-footer">
|
||||
<ul className="list">
|
||||
<li>
|
||||
<a href="#">Imprint</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Our Privacy Policy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<div className="box">
|
||||
<h2>Social Media</h2>
|
||||
<NavSocial />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
export default FooterMain;
|
||||
@@ -0,0 +1,21 @@
|
||||
import LogoMain from '../logos/LogoMain';
|
||||
import SliderSplide from '../sliders/SliderSplide';
|
||||
|
||||
const HeaderMain = () => {
|
||||
const images = [
|
||||
'/img/slides/slide-01.jpg', //
|
||||
'/img/slides/slide-02.jpg',
|
||||
'/img/slides/slide-03.jpg',
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="m-header-main header-main">
|
||||
<SliderSplide images={images} />
|
||||
<div className="content-box">
|
||||
<LogoMain />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderMain;
|
||||
@@ -0,0 +1,62 @@
|
||||
const Logo = (props) => {
|
||||
// const { } = props;
|
||||
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 148.14 90.61">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="76.09" x2="76.09" y1="9.78" y2="56.04" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#65c1c3" />
|
||||
<stop offset="1" stopColor="#569ed6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="239.45"
|
||||
x2="220.4"
|
||||
y1="312.2"
|
||||
y2="331.38"
|
||||
gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#5784c3" />
|
||||
<stop offset=".2" stopColor="#5480c0" />
|
||||
<stop offset=".39" stopColor="#4c74b8" />
|
||||
<stop offset=".58" stopColor="#3f60aa" />
|
||||
<stop offset=".67" stopColor="#3856a3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="c"
|
||||
x1="228.06"
|
||||
x2="228.06"
|
||||
y1="319.94"
|
||||
y2="351.23"
|
||||
gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#b"
|
||||
id="d"
|
||||
x1="-129.62"
|
||||
x2="-148.68"
|
||||
y1="17.01"
|
||||
y2="36.19"
|
||||
gradientTransform="matrix(-1 0 0 1 -33.63 0)"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="e"
|
||||
x1="-141.01"
|
||||
x2="-141.01"
|
||||
y1="24.75"
|
||||
y2="56.04"
|
||||
gradientTransform="matrix(-1 0 0 1 -33.63 0)"
|
||||
/>
|
||||
</defs>
|
||||
<path fill="url(#a)" d="M73.98 56.04H60.7L78.21 9.78h13.28z" />
|
||||
<path d="M15.96 66.6h2.13l.98 10.8h.04l1.04-10.8h2.41l1.04 10.8h.04l.98-10.8h1.91L25.1 80.65h-2.75l-1-9.48h-.04l-1 9.48h-2.93L15.95 66.6ZM27.66 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM34.95 66.6h3.33c2.29 0 3.27 1.06 3.27 3.23v.56c0 1.45-.44 2.35-1.43 2.75v.04c1.18.4 1.65 1.44 1.65 2.93v1.21c0 2.17-1.14 3.33-3.35 3.33h-3.47zm3.07 5.72c.82 0 1.33-.36 1.33-1.49v-.78c0-1-.34-1.45-1.12-1.45h-1.06v3.71h.86Zm.41 6.32c.74 0 1.14-.34 1.14-1.39v-1.22c0-1.31-.42-1.71-1.43-1.71h-.98v4.32h1.26ZM46.28 66.6h3.37c2.21 0 3.29 1.22 3.29 3.47v7.11c0 2.25-1.08 3.47-3.29 3.47h-3.37zm3.33 12.04c.7 0 1.12-.36 1.12-1.37v-7.31c0-1-.42-1.37-1.12-1.37h-1.12v10.04h1.12ZM54.43 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM61.13 66.6h2.23l1.45 10.9h.04l1.45-10.9h2.03L66.2 80.65h-2.93L61.14 66.6ZM69.38 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM76.67 66.6h2.21v12.05h3.63v2.01h-5.84V66.61ZM83.34 77.32v-7.39c0-2.25 1.18-3.53 3.35-3.53s3.35 1.28 3.35 3.53v7.39c0 2.25-1.18 3.53-3.35 3.53s-3.35-1.28-3.35-3.53m4.49.14v-7.67c0-1-.44-1.39-1.14-1.39s-1.14.38-1.14 1.39v7.67c0 1 .44 1.39 1.14 1.39s1.14-.38 1.14-1.39M91.53 66.6h3.25c2.21 0 3.29 1.22 3.29 3.47v1.39c0 2.25-1.08 3.47-3.29 3.47h-1.04v5.72h-2.21zm3.25 6.32c.7 0 1.08-.32 1.08-1.32v-1.67c0-1-.38-1.33-1.08-1.33h-1.04v4.32zM99.28 66.6h3.15l1.41 10.06h.04l1.41-10.06h3.15v14.05h-2.09V70.01h-.04l-1.61 10.64h-1.85l-1.61-10.64h-.04v10.64h-1.93V66.6ZM110.08 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM117.36 66.6h2.77l2.15 8.41h.04V66.6h1.97v14.05h-2.27l-2.65-10.26h-.04v10.26h-1.97zM127.66 68.61h-2.31V66.6h6.83v2.01h-2.31v12.05h-2.21z" />
|
||||
<path fill="url(#b)" d="M51.27 56.03V39.76L36.26 24.75l-4.65 4.65a4.943 4.943 0 0 0 0 6.99l19.64 19.64.02-.02Z" />
|
||||
<path fill="url(#c)" d="M51.26 26.05V9.78l-.02-.02-19.67 19.67a4.913 4.913 0 0 0 0 6.95l4.68 4.68 8.13-8.13z" />
|
||||
<path fill="url(#d)" d="M96.81 9.78v16.27l15.01 15.01 4.65-4.65a4.943 4.943 0 0 0 0-6.99L96.83 9.78l-.02.02Z" />
|
||||
<path fill="url(#e)" d="M96.82 39.76v16.27l.02.02 19.67-19.67a4.913 4.913 0 0 0 0-6.95l-4.68-4.68-8.13 8.13z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export default Logo;
|
||||
@@ -0,0 +1,57 @@
|
||||
// import { useState } from 'react';
|
||||
import { FaMinus, FaPlus } from 'react-icons/fa6';
|
||||
|
||||
const InputAmount = (props) => {
|
||||
const { min = 0, max = 100, amount = 0 } = props;
|
||||
// const [amount, setAmount] = useState(props.amount || 0);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const inputValue = e.target.value;
|
||||
const numericRegExp = /^-?\d*$/; // regular expression for numbers (including optional minus sign)
|
||||
|
||||
if (numericRegExp.test(inputValue)) {
|
||||
// setAmount(inputValue);
|
||||
props.onHandleAmount(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickIncrement = () => {
|
||||
if (amount < max) {
|
||||
// setAmount((amount) => amount + 1);
|
||||
props.onHandleAmount(amount + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickDecrement = () => {
|
||||
if (amount > min) {
|
||||
// setAmount((amount) => amount - 1);
|
||||
props.onHandleAmount(amount - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-input-amount">
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">
|
||||
<button className="btn btn-dark button-dec" onClick={handleClickDecrement} disabled={amount === min}>
|
||||
<FaMinus />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control input-amount"
|
||||
aria-label="Amount"
|
||||
onChange={handleChange}
|
||||
value={amount}
|
||||
/>
|
||||
<span className="input-group-text">
|
||||
<button className="btn btn-dark button-inc" onClick={handleClickIncrement} disabled={amount === max}>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputAmount;
|
||||
@@ -0,0 +1,57 @@
|
||||
// import { useState } from 'react';
|
||||
|
||||
const isValidPrice = (value) => {
|
||||
// regular expression for price (up to 2 decimal places)
|
||||
const regex = new RegExp(/^\d+(\.\d{0,2})?$/);
|
||||
return regex.test(value);
|
||||
};
|
||||
|
||||
const InputPrice = (props) => {
|
||||
const { currency = '$', price = 0 } = props;
|
||||
|
||||
// const [price, setPrice] = useState(parseFloat(props.price) || 0);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const inputValue = parseFloat(e.target.value);
|
||||
|
||||
// setPrice(inputValue);
|
||||
props.onHandlePrice(inputValue);
|
||||
};
|
||||
const handleBlur = (e) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (!isValidPrice(inputValue)) {
|
||||
// setPrice(0);
|
||||
props.onHandlePrice(0);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrency = () => {
|
||||
if (currency === '$') {
|
||||
return <div className="input-group-text">{currency}</div>;
|
||||
} else if (currency === '€' || currency === '¥') {
|
||||
return <div className="input-group-text">{currency}</div>;
|
||||
} else {
|
||||
return null; // or throw an error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-input-price">
|
||||
<div className="input-group mb-3">
|
||||
{currency === '$' && renderCurrency()}
|
||||
<input
|
||||
type="number"
|
||||
className={`form-control input-price ${!isValidPrice(price) ? 'is-invalid' : ''} `}
|
||||
aria-label="Price"
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={price}
|
||||
/>
|
||||
{(currency === '€' || currency === '¥') && renderCurrency()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputPrice;
|
||||
@@ -0,0 +1,14 @@
|
||||
// import Logo from '../../assets/img/logo-js.min.svg';
|
||||
import Logo from '../icons/Logo';
|
||||
|
||||
const LogoMain = (props) => {
|
||||
// const { } = props;
|
||||
|
||||
return (
|
||||
<figure className="m-logo-main logo-main">
|
||||
{/* <img src={Logo} alt="React" /> */}
|
||||
<Logo />
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
export default LogoMain;
|
||||
135
webseite-react-php-jwt/react-app/src/components/navs/NavMain.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuthActions, useAuthState } from '../../stores/auth';
|
||||
|
||||
const NavMain = () => {
|
||||
const { user, loading, error } = useAuthState();
|
||||
const { login, logout } = useAuthActions();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
} catch {
|
||||
// Fehler kommt aus dem Auth-Store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="m-nav-main nav-main navbar navbar-expand-lg bg-body-dark">
|
||||
<div className="container-fluid">
|
||||
<NavLink className="navbar-brand" to="/">
|
||||
React SPA
|
||||
</NavLink>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNavDropdown"
|
||||
aria-controls="navbarNavDropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div className="collapse navbar-collapse" id="navbarNavDropdown">
|
||||
<ul className="navbar-nav">
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to="/">
|
||||
Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to="/about">
|
||||
About
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item dropdown">
|
||||
<NavLink
|
||||
className="nav-link dropdown-toggle"
|
||||
to="/projects"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
Projects
|
||||
</NavLink>
|
||||
<ul className="dropdown-menu">
|
||||
<li>
|
||||
<NavLink className="dropdown-item" to="/projects/table-products">
|
||||
Table Products
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink className="dropdown-item" to="/projects/table-users">
|
||||
Table Users
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to="/contact">
|
||||
Contact
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="nav-auth ms-lg-auto">
|
||||
{user ? (
|
||||
<div className="nav-auth-session">
|
||||
<span className="nav-auth-user">{user.username}</span>
|
||||
<button type="button" className="btn btn-sm btn-outline-light" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="nav-auth-form" onSubmit={handleLogin}>
|
||||
<label className="visually-hidden" htmlFor="nav-login-username">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
id="nav-login-username"
|
||||
className="form-control form-control-sm"
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder="Benutzername"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<label className="visually-hidden" htmlFor="nav-login-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="nav-login-password"
|
||||
className="form-control form-control-sm"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Passwort"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="btn btn-sm btn-warning" type="submit" disabled={loading}>
|
||||
Login
|
||||
</button>
|
||||
{error && (
|
||||
<p className="nav-auth-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavMain;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FaYoutube, FaXTwitter, FaFacebookF, FaInstagram } from 'react-icons/fa6';
|
||||
|
||||
const NavSocial = () => {
|
||||
return (
|
||||
<nav className="m-nav-social nav-social">
|
||||
<ul className="list">
|
||||
<li>
|
||||
<a href="#" target="_blank" rel="nofollow" aria-label="x-twitter">
|
||||
<FaXTwitter />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" aria-label="youtube">
|
||||
<FaYoutube />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.facebook.com/gfn.gmbh/?locale=de_DE"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
aria-label="facebook">
|
||||
<FaFacebookF />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" aria-label="instagram">
|
||||
<FaInstagram />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
export default NavSocial;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Splide, SplideSlide } from '@splidejs/react-splide';
|
||||
|
||||
const SliderSplide = (props) => {
|
||||
const {
|
||||
images = [],
|
||||
label = 'JS Header Slides',
|
||||
options = {
|
||||
rewind: true,
|
||||
height: '100%',
|
||||
autoplay: true,
|
||||
speed: 800,
|
||||
type: 'fade',
|
||||
},
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{images && images.length > 0 && (
|
||||
<div className="m-slider-splide slider-splide">
|
||||
<Splide aria-label={label} options={options}>
|
||||
{images.map((image, idx) => (
|
||||
<SplideSlide key={`splide-slide-${idx}`}>
|
||||
<img src={image} alt="" />
|
||||
</SplideSlide>
|
||||
))}
|
||||
</Splide>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SliderSplide;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ProductsProvider, useProductsActions, useProductsState } from '../../stores/products';
|
||||
import { useAuthState } from '../../stores/auth';
|
||||
import RowProduct from './content/RowProduct';
|
||||
|
||||
const TableProductsView = () => {
|
||||
const { user } = useAuthState();
|
||||
const { items, loading, error } = useProductsState();
|
||||
const { loadProducts } = useProductsActions();
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return items.reduce((sum, obj) => {
|
||||
return sum + Number(obj.price) * Number(obj.stock);
|
||||
}, 0);
|
||||
}, [items]);
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return <p className="text-muted">Produkte werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-table-products table-products py-5">
|
||||
<div className="container">
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<p className="mb-2">{error}</p>
|
||||
<button type="button" className="btn btn-sm btn-dark" onClick={loadProducts}>
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="table table-striped">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Stock</th>
|
||||
<th>Title</th>
|
||||
<th>Price</th>
|
||||
<th>Total</th>
|
||||
{user && <th className="col-actions">Admin</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 &&
|
||||
items.map((item) => (
|
||||
<RowProduct key={`row-product-${item._id}`} {...item} canManage={Boolean(user)} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>Total:</td>
|
||||
<td className="col-total">${totalPrice.toFixed(2)}</td>
|
||||
{user && <td className="col-actions" />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableProducts = () => {
|
||||
return (
|
||||
<ProductsProvider>
|
||||
<TableProductsView />
|
||||
</ProductsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableProducts;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { UsersProvider, useUsersActions, useUsersState } from '../../stores/users';
|
||||
import { useAuthState } from '../../stores/auth';
|
||||
import RowUser from './content/RowUser';
|
||||
|
||||
const TableUsersView = () => {
|
||||
const { user } = useAuthState();
|
||||
const { items, loading, error } = useUsersState();
|
||||
const { loadUsers } = useUsersActions();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="status">
|
||||
Bitte oben rechts einloggen, um die User-Tabelle zu sehen.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return <p className="text-muted">Benutzer werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-table-users table-users">
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<p className="mb-2">{error}</p>
|
||||
<button type="button" className="btn btn-sm btn-dark" onClick={loadUsers}>
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="table table-striped">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Angelegt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<RowUser key={`row-user-${item.id}`} {...item} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableUsers = () => {
|
||||
return (
|
||||
<UsersProvider>
|
||||
<TableUsersView />
|
||||
</UsersProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableUsers;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { FaPen, FaTrash } from 'react-icons/fa6';
|
||||
import InputAmount from '../../inputs/InputAmount';
|
||||
import InputPrice from '../../inputs/InputPrice';
|
||||
import { useProductsActions } from '../../../stores/products';
|
||||
|
||||
const RowProduct = (props) => {
|
||||
const {
|
||||
_id,
|
||||
title,
|
||||
stock: amount = 0,
|
||||
price = 0,
|
||||
sku = '',
|
||||
canManage = false,
|
||||
} = props;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { updateAmount, updatePrice, removeProduct } = useProductsActions();
|
||||
|
||||
const handleAmount = useCallback(
|
||||
(value) => {
|
||||
updateAmount(value, _id);
|
||||
},
|
||||
[updateAmount, _id]
|
||||
);
|
||||
|
||||
const handlePrice = useCallback(
|
||||
(value) => {
|
||||
updatePrice(value, _id);
|
||||
},
|
||||
[updatePrice, _id]
|
||||
);
|
||||
|
||||
const handleToggleEdit = useCallback(() => {
|
||||
setIsEditing((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
const confirmed = window.confirm(`Produkt „${title}“ wirklich entfernen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeProduct(_id);
|
||||
}, [removeProduct, _id, title]);
|
||||
|
||||
return (
|
||||
<tr className={`row-product ${isEditing ? 'is-editing' : ''}`}>
|
||||
<td>{sku}</td>
|
||||
<td>
|
||||
{canManage && isEditing ? (
|
||||
<InputAmount amount={amount} onHandleAmount={handleAmount} />
|
||||
) : (
|
||||
amount
|
||||
)}
|
||||
</td>
|
||||
<td>{title}</td>
|
||||
<td>
|
||||
{canManage && isEditing ? (
|
||||
<InputPrice price={price} onHandlePrice={handlePrice} />
|
||||
) : (
|
||||
Number(price).toFixed(2)
|
||||
)}
|
||||
</td>
|
||||
<td className="col-total">{(Number(amount) * Number(price)).toFixed(2)}</td>
|
||||
{canManage && (
|
||||
<td className="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-action btn-action-edit ${isEditing ? 'is-active' : ''}`}
|
||||
aria-label={isEditing ? 'Bearbeiten beenden' : 'Bearbeiten'}
|
||||
aria-pressed={isEditing}
|
||||
onClick={handleToggleEdit}>
|
||||
<FaPen />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-action btn-action-remove"
|
||||
aria-label="Entfernen"
|
||||
onClick={handleRemove}>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RowProduct);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
const RowUser = (props) => {
|
||||
const { id, username, email, created_at: createdAt = '' } = props;
|
||||
|
||||
return (
|
||||
<tr className="row-user">
|
||||
<td>{id}</td>
|
||||
<td>{username}</td>
|
||||
<td>{email}</td>
|
||||
<td>{createdAt}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RowUser);
|
||||
84
webseite-react-php-jwt/react-app/src/docs/PLAN.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Projekt-Roadmap: Phasenplan
|
||||
|
||||
Übersicht über die schrittweise Entwicklung von der lokalen Client-App bis zum modernen Next.js + Convex Stack deployed via Coolify.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Übersicht
|
||||
|
||||
| Phase | Fokus | Verzeichnis / Module | Stack | Hosting / Ziel |
|
||||
| :---------- | :-------------------------- | :-------------------------------------------------- | :-------------------------------------------- | :------------------------------- |
|
||||
| **Phase 1** | Client-Prototyp & State | `/react-app` | React + Vite, `useContext`, `useReducer` | Lokal |
|
||||
| **Phase 2** | Classic Fullstack & MariaDB | `/react-server/frontend`<br>`/react-server/backend` | React SPA + Slim PHP (REST API, PDO, MariaDB) | Apache Webspace / Shared Hosting |
|
||||
| **Phase 3** | Modern Fullstack Rewrite | `/next-app` | Next.js (App Router, RSC/SSG) + Convex BaaS | Lokal / Staging |
|
||||
| **Phase 4** | Production Deployment | `/next-app` + Infrastruktur | Coolify (Self-hosted PaaS) + Convex Cloud | VPS / Production Server |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Client-Only SPA (`/react-app`)
|
||||
|
||||
**Ziel:** Schnelles Prototyping der Benutzeroberfläche und Etablierung des lokalen UI-State-Managements ohne Backend-Abhängigkeit.
|
||||
|
||||
- **Stack & Setup:**
|
||||
- React + Vite
|
||||
- Globaler State via `useContext` und `useReducer` (zur Vermeidung von Prop Drilling und State Uplifting)
|
||||
- Mock-Daten / LocalStorage für initiale Datenhaltung
|
||||
- **Meilensteine:**
|
||||
- [ ] Grundlegendes Layout und Komponentenstruktur aufbauen
|
||||
- [ ] State-Reducer für UI- und Dummy-Datenflüsse implementieren
|
||||
- [ ] Service-Layer für Datenzugriffe vorbereiten (gekapselte Mock-APIs)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Klassisches Client-Server-Modell (`/react-server`)
|
||||
|
||||
**Ziel:** Anbindung einer relationalen Datenbank über eine schlanke REST-API, lauffähig auf Standard-PHP/Apache-Webspaces ohne Node-Hosting-Zwang.
|
||||
|
||||
- **Struktur:**
|
||||
- `/react-server/frontend`: Die React-App aus Phase 1 (Vite Build / statisches Bundle in `dist/`)
|
||||
- `/react-server/backend`: Schlanke PHP-Schnittstelle
|
||||
- **Stack & Setup:**
|
||||
- Backend: Slim Framework (PHP) + PDO
|
||||
- Datenbank: MariaDB / MySQL
|
||||
- Routing & Server: Apache `.htaccess` (URL-Rewriting für Slim & SPA-Routing)
|
||||
- CORS-Middleware für lokale Entwicklung
|
||||
- **Meilensteine:**
|
||||
- [ ] MariaDB-Datenbankschema erstellen
|
||||
- [ ] REST-Endpunkte in Slim PHP anlegen (`GET`, `POST`, `PUT`, `DELETE`)
|
||||
- [ ] Frontend-API-Services von Mock-Daten auf HTTP-Requests (`fetch`) umstellen
|
||||
- [ ] Deployment auf klassischem Apache-Webspace testen
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Modern Stack Rewrite & Vibe Coding (`/next-app`)
|
||||
|
||||
**Ziel:** Kompletter Neuaufbau mit moderner Architektur, nativer SEO-Unterstützung und reaktiver Echtzeit-Datenbank.
|
||||
|
||||
- **Stack & Setup:**
|
||||
- Next.js (App Router, React Server Components, SSG)
|
||||
- Convex (BaaS, Schema-Validierung, WebSocket-Echtzeit, Server Functions)
|
||||
- SEO: `sitemap.ts`, `robots.ts`, native `generateMetadata`-API
|
||||
- **Meilensteine:**
|
||||
- [ ] Neues Next.js-Projekt aufsetzen (`/next-app`)
|
||||
- [ ] `convex/schema.ts` definieren (Übernahme der Datenstruktur aus MariaDB)
|
||||
- [ ] Convex Backend Functions (Queries & Mutations) implementieren
|
||||
- [ ] UI-Komponenten auf Next.js App Router und Convex-Hooks (`useQuery`, `useMutation`) portieren
|
||||
- [ ] Statische Seiten / SSG für suchmaschinenrelevante Bereiche konfigurieren
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Deployment & Infrastruktur mit Coolify
|
||||
|
||||
**Ziel:** Automatisiertes, wartungsarmes Hosting der Next.js-Applikation über eine eigene Coolify-Instanz.
|
||||
|
||||
- **Stack & Setup:**
|
||||
- Coolify (Self-hosted PaaS via Docker auf VPS)
|
||||
- Next.js Standalone Build
|
||||
- Convex Cloud / Self-Hosted Backend Connection
|
||||
- Automatisches Git-Deployment (Webhooks, CI/CD)
|
||||
- **Meilensteine:**
|
||||
- [ ] `output: "standalone"` in `next.config.js` konfigurieren
|
||||
- [ ] Dockerfile für Next.js optimieren (Multi-Stage Build für geringe Image-Größe)
|
||||
- [ ] Applikation in Coolify als Service / Git-Repository verknüpfen
|
||||
- [ ] Environment Variables einrichten (`CONVEX_DEPLOYMENT`, `NEXT_PUBLIC_CONVEX_URL`, etc.)
|
||||
- [ ] SSL-Zertifikate, Domain-Routing und Health-Checks in Coolify verifizieren
|
||||
16
webseite-react-php-jwt/react-app/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import './styles/main.scss';
|
||||
import 'bootstrap';
|
||||
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
14
webseite-react-php-jwt/react-app/src/pages/AboutPage.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<div className="page page-about">
|
||||
<div className="container py-5">
|
||||
<h2>About us</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Qui rerum, fugit ipsa optio laboriosam sed est quae
|
||||
explicabo! Reiciendis, iure soluta ad libero esse cupiditate pariatur nostrum accusantium! Obcaecati, in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AboutPage;
|
||||
11
webseite-react-php-jwt/react-app/src/pages/ContactPage.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
const ContactPage = () => {
|
||||
return (
|
||||
<div className="page page-contact">
|
||||
<div className="container py-5">
|
||||
<h2>Contact</h2>
|
||||
<p>Lorem ipsum dolor sit amet consectetur</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ContactPage;
|
||||
10
webseite-react-php-jwt/react-app/src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<div className="page page-home">
|
||||
<div className="container py-5">
|
||||
<h2>Hello from SPA!</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default HomePage;
|
||||
10
webseite-react-php-jwt/react-app/src/pages/ProjectsPage.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const ProjectsPage = () => {
|
||||
return (
|
||||
<div className="page page-projects">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProjectsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import TableProducts from '../../components/tables/TableProducts';
|
||||
|
||||
const TableProductsPage = () => {
|
||||
return (
|
||||
<div className="page page-projects-table-products">
|
||||
<div className="container py-5">
|
||||
<h2>Table Products</h2>
|
||||
<p>Produkte öffentlich sichtbar – Bearbeiten und Löschen nur nach Login</p>
|
||||
<TableProducts />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableProductsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import TableUsers from '../../components/tables/TableUsers';
|
||||
|
||||
const TableUsersPage = () => {
|
||||
return (
|
||||
<div className="page page-projects-table-users">
|
||||
<div className="container py-5">
|
||||
<h2>Table Users</h2>
|
||||
<p>Benutzer aus der Slim REST API</p>
|
||||
<TableUsers />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableUsersPage;
|
||||
23
webseite-react-php-jwt/react-app/src/routes/Router.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import HomePage from '../pages/HomePage';
|
||||
import AboutPage from '../pages/AboutPage';
|
||||
import ContactPage from '../pages/ContactPage';
|
||||
import ProjectsPage from '../pages/ProjectsPage';
|
||||
import TableProductsPage from '../pages/projects/TableProductsPage';
|
||||
import TableUsersPage from '../pages/projects/TableUsersPage';
|
||||
|
||||
const Router = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
|
||||
<Route path="/projects" element={<ProjectsPage />}>
|
||||
<Route path="table-products" element={<TableProductsPage />} />
|
||||
<Route path="table-users" element={<TableUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
export default Router;
|
||||
127
webseite-react-php-jwt/react-app/src/stores/auth/AuthStore.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
|
||||
import { setAuthToken, setOnUnauthorized } from '../http';
|
||||
import { fetchMe, getApiErrorMessage, loginRequest, logoutRequest } from './api';
|
||||
import { createInitialState, persistAuth } from './initialState';
|
||||
import { AUTH_ACTIONS, authReducer } from './reducer';
|
||||
|
||||
const AuthStateContext = createContext(null);
|
||||
const AuthActionsContext = createContext(null);
|
||||
|
||||
export const AuthProvider = (props) => {
|
||||
const { children } = props;
|
||||
const [state, dispatch] = useReducer(authReducer, undefined, createInitialState);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setAuthToken(state.token);
|
||||
}, [state.token]);
|
||||
|
||||
useEffect(() => {
|
||||
persistAuth(state.user, state.token);
|
||||
}, [state.user, state.token]);
|
||||
|
||||
useEffect(() => {
|
||||
return setOnUnauthorized(() => {
|
||||
persistAuth(null, null);
|
||||
dispatch({ type: AUTH_ACTIONS.CLEAR });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const restore = async () => {
|
||||
try {
|
||||
const user = await fetchMe({ signal: controller.signal });
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.SET_SESSION,
|
||||
payload: { user, token: state.token },
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthToken(null);
|
||||
persistAuth(null, null);
|
||||
dispatch({ type: AUTH_ACTIONS.CLEAR });
|
||||
}
|
||||
};
|
||||
|
||||
restore();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: true });
|
||||
|
||||
try {
|
||||
const session = await loginRequest(username, password);
|
||||
setAuthToken(session.token);
|
||||
dispatch({ type: AUTH_ACTIONS.SET_SESSION, payload: session });
|
||||
return session.user;
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
const message =
|
||||
status === 401 ? 'Benutzername oder Passwort ist falsch' : getApiErrorMessage(error);
|
||||
|
||||
dispatch({ type: AUTH_ACTIONS.SET_ERROR, payload: message });
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutRequest();
|
||||
} catch {
|
||||
// Session lokal trotzdem beenden
|
||||
} finally {
|
||||
setAuthToken(null);
|
||||
persistAuth(null, null);
|
||||
dispatch({ type: AUTH_ACTIONS.CLEAR });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const actions = useMemo(() => ({ login, logout }), [login, logout]);
|
||||
|
||||
return (
|
||||
<AuthStateContext.Provider value={state}>
|
||||
<AuthActionsContext.Provider value={actions}>{children}</AuthActionsContext.Provider>
|
||||
</AuthStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuthState = () => {
|
||||
const state = useContext(AuthStateContext);
|
||||
|
||||
if (state == null) {
|
||||
throw new Error('useAuthState muss innerhalb von AuthProvider verwendet werden');
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useAuthActions = () => {
|
||||
const actions = useContext(AuthActionsContext);
|
||||
|
||||
if (actions == null) {
|
||||
throw new Error('useAuthActions muss innerhalb von AuthProvider verwendet werden');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
22
webseite-react-php-jwt/react-app/src/stores/auth/api.js
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { apiClient, getApiErrorMessage } from '../http';
|
||||
|
||||
export { getApiErrorMessage };
|
||||
|
||||
export const loginRequest = async (username, password) => {
|
||||
const { data } = await apiClient.post('/login', { username, password });
|
||||
|
||||
if (!data?.user || !data?.token) {
|
||||
throw new Error('Ungültige API-Antwort: Session erwartet');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const logoutRequest = async () => {
|
||||
await apiClient.post('/logout');
|
||||
};
|
||||
|
||||
export const fetchMe = async (config = {}) => {
|
||||
const { data } = await apiClient.get('/me', config);
|
||||
return data;
|
||||
};
|
||||
4
webseite-react-php-jwt/react-app/src/stores/auth/index.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createInitialState } from './initialState';
|
||||
export { AUTH_ACTIONS, authReducer } from './reducer';
|
||||
export { AuthProvider, useAuthActions, useAuthState } from './AuthStore';
|
||||
export { fetchMe, getApiErrorMessage, loginRequest, logoutRequest } from './api';
|
||||
38
webseite-react-php-jwt/react-app/src/stores/auth/initialState.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
const STORAGE_KEY = 'nerdshop.auth';
|
||||
|
||||
export const loadStoredAuth = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { user: null, token: null };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.token && parsed?.user) {
|
||||
return { user: parsed.user, token: parsed.token };
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
return { user: null, token: null };
|
||||
};
|
||||
|
||||
export const persistAuth = (user, token) => {
|
||||
if (user && token) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ user, token }));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
export const createInitialState = () => {
|
||||
const stored = loadStoredAuth();
|
||||
|
||||
return {
|
||||
user: stored.user,
|
||||
token: stored.token,
|
||||
loading: Boolean(stored.token),
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
41
webseite-react-php-jwt/react-app/src/stores/auth/reducer.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
export const AUTH_ACTIONS = {
|
||||
SET_LOADING: 'SET_LOADING',
|
||||
SET_SESSION: 'SET_SESSION',
|
||||
SET_ERROR: 'SET_ERROR',
|
||||
CLEAR: 'CLEAR',
|
||||
};
|
||||
|
||||
export const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case AUTH_ACTIONS.SET_LOADING:
|
||||
return {
|
||||
...state,
|
||||
loading: action.payload,
|
||||
error: action.payload ? null : state.error,
|
||||
};
|
||||
case AUTH_ACTIONS.SET_SESSION:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
case AUTH_ACTIONS.SET_ERROR:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
case AUTH_ACTIONS.CLEAR:
|
||||
return {
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
default:
|
||||
console.warn('no action found');
|
||||
return state;
|
||||
}
|
||||
};
|
||||
83
webseite-react-php-jwt/react-app/src/stores/http.js
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios';
|
||||
import { loadStoredAuth } from './auth/initialState';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? '/api',
|
||||
timeout: 8000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
let authToken = null;
|
||||
let onUnauthorized = null;
|
||||
|
||||
const isLoginRequest = (config) => {
|
||||
const url = config?.url ?? '';
|
||||
return url === '/login' || url.endsWith('/login');
|
||||
};
|
||||
|
||||
const resolveAuthToken = () => authToken || loadStoredAuth().token || null;
|
||||
|
||||
const applyAuthorizationHeader = (headers, token) => {
|
||||
const value = `Bearer ${token}`;
|
||||
|
||||
if (headers && typeof headers.set === 'function') {
|
||||
headers.set('Authorization', value);
|
||||
return headers;
|
||||
}
|
||||
|
||||
return { ...(headers || {}), Authorization: value };
|
||||
};
|
||||
|
||||
export const setAuthToken = (token) => {
|
||||
authToken = token || null;
|
||||
|
||||
if (authToken) {
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${authToken}`;
|
||||
} else {
|
||||
delete apiClient.defaults.headers.common.Authorization;
|
||||
}
|
||||
};
|
||||
|
||||
setAuthToken(loadStoredAuth().token);
|
||||
|
||||
export const setOnUnauthorized = (handler) => {
|
||||
onUnauthorized = handler;
|
||||
|
||||
return () => {
|
||||
if (onUnauthorized === handler) {
|
||||
onUnauthorized = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = resolveAuthToken();
|
||||
|
||||
if (token && !isLoginRequest(config)) {
|
||||
config.headers = applyAuthorizationHeader(config.headers, token);
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
|
||||
if (status === 401 && !isLoginRequest(error.config)) {
|
||||
setAuthToken(null);
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const getApiErrorMessage = (error) => {
|
||||
if (axios.isCancel(error) || error.code === 'ERR_CANCELED') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return error.response?.data?.error || error.message || 'Unbekannter API-Fehler';
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { apiClient } from '../http';
|
||||
import { createInitialState } from './initialState';
|
||||
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
||||
|
||||
import {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
fetchProducts,
|
||||
getApiErrorMessage,
|
||||
patchProduct,
|
||||
updateProduct,
|
||||
} from './api';
|
||||
|
||||
const PERSIST_DELAY_MS = 400;
|
||||
|
||||
const ProductsStateContext = createContext(null);
|
||||
const ProductsDispatchContext = createContext(null);
|
||||
const ProductsActionsContext = createContext(null);
|
||||
|
||||
export const ProductsProvider = (props) => {
|
||||
const { children, items = [], autoLoad = true } = props;
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
productsReducer,
|
||||
items,
|
||||
createInitialState, // Der 3. Parameter dient der Lazy Initialization (verzögerten/faulen Initialisierung) des States.
|
||||
);
|
||||
|
||||
// Warum macht man das?
|
||||
|
||||
// Performance (Expensive Calculations): Wenn die Erstellung des Ausgangszustands rechenintensiv ist (z. B. Parsen von localStorage, Filtern/Sortieren großer Arrays), wird createInitialState nur ein einziges Mal beim Mounten ausgeführt – nicht bei jedem weiteren Render.
|
||||
|
||||
// Wiederverwendbarkeit: Du kannst dieselbe createInitialState-Funktion auch innerhalb deines Reducers aufrufen, um z. B. bei einer RESET-Action den State sauber auf die Ausgangswerte zurückzusetzen.
|
||||
|
||||
const pendingPatches = useRef(new Map());
|
||||
const persistTimers = useRef(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoLoad) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
|
||||
|
||||
try {
|
||||
const loadedItems = await fetchProducts({ signal: controller.signal });
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [autoLoad]);
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
|
||||
|
||||
try {
|
||||
const loadedItems = await fetchProducts();
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushPatch = useCallback(
|
||||
async (id) => {
|
||||
const patch = pendingPatches.current.get(id);
|
||||
pendingPatches.current.delete(id);
|
||||
|
||||
if (persistTimers.current.has(id)) {
|
||||
clearTimeout(persistTimers.current.get(id));
|
||||
persistTimers.current.delete(id);
|
||||
}
|
||||
|
||||
if (!patch || Object.keys(patch).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await patchProduct(id, patch);
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
await loadProducts();
|
||||
}
|
||||
},
|
||||
[loadProducts],
|
||||
);
|
||||
|
||||
const schedulePatch = useCallback(
|
||||
(id, patch) => {
|
||||
const merged = { ...(pendingPatches.current.get(id) || {}), ...patch };
|
||||
pendingPatches.current.set(id, merged);
|
||||
|
||||
if (persistTimers.current.has(id)) {
|
||||
clearTimeout(persistTimers.current.get(id));
|
||||
}
|
||||
|
||||
persistTimers.current.set(
|
||||
id,
|
||||
setTimeout(() => {
|
||||
flushPatch(id);
|
||||
}, PERSIST_DELAY_MS),
|
||||
);
|
||||
},
|
||||
[flushPatch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = persistTimers.current;
|
||||
const patches = pendingPatches.current;
|
||||
|
||||
return () => {
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
|
||||
patches.forEach((patch, id) => {
|
||||
if (
|
||||
patch &&
|
||||
Object.keys(patch).length > 0 &&
|
||||
apiClient.defaults.headers.common.Authorization
|
||||
) {
|
||||
patchProduct(id, patch).catch(() => {});
|
||||
}
|
||||
});
|
||||
patches.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateAmount = useCallback(
|
||||
(amount, id) => {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.UPDATE_AMOUNT,
|
||||
payload: { id, amount },
|
||||
});
|
||||
|
||||
const stock = Number(amount);
|
||||
if (Number.isFinite(stock)) {
|
||||
schedulePatch(id, { stock });
|
||||
}
|
||||
},
|
||||
[schedulePatch],
|
||||
);
|
||||
|
||||
const updatePrice = useCallback(
|
||||
(price, id) => {
|
||||
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_PRICE, payload: { id, price } });
|
||||
|
||||
const numericPrice = Number(price);
|
||||
if (Number.isFinite(numericPrice)) {
|
||||
schedulePatch(id, { price: numericPrice });
|
||||
}
|
||||
},
|
||||
[schedulePatch],
|
||||
);
|
||||
|
||||
const addProduct = useCallback(async (product) => {
|
||||
try {
|
||||
const created = await createProduct(product);
|
||||
dispatch({ type: PRODUCTS_ACTIONS.ADD_ITEM, payload: created });
|
||||
return created;
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveProduct = useCallback(async (id, product) => {
|
||||
try {
|
||||
const updated = await updateProduct(id, product);
|
||||
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
|
||||
return updated;
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeProduct = useCallback(async (id) => {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
dispatch({ type: PRODUCTS_ACTIONS.REMOVE_ITEM, payload: id });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: PRODUCTS_ACTIONS.SET_ERROR,
|
||||
payload: getApiErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
loadProducts,
|
||||
updateAmount,
|
||||
updatePrice,
|
||||
addProduct,
|
||||
saveProduct,
|
||||
removeProduct,
|
||||
}),
|
||||
[
|
||||
loadProducts,
|
||||
updateAmount,
|
||||
updatePrice,
|
||||
addProduct,
|
||||
saveProduct,
|
||||
removeProduct,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProductsStateContext.Provider value={state}>
|
||||
<ProductsDispatchContext.Provider value={dispatch}>
|
||||
<ProductsActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
</ProductsActionsContext.Provider>
|
||||
</ProductsDispatchContext.Provider>
|
||||
</ProductsStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useProductsState = () => {
|
||||
const state = useContext(ProductsStateContext);
|
||||
|
||||
if (state == null) {
|
||||
throw new Error(
|
||||
'useProductsState muss innerhalb von ProductsProvider verwendet werden',
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useProductsDispatch = () => {
|
||||
const dispatch = useContext(ProductsDispatchContext);
|
||||
|
||||
if (dispatch == null) {
|
||||
throw new Error(
|
||||
'useProductsDispatch muss innerhalb von ProductsProvider verwendet werden',
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch;
|
||||
};
|
||||
|
||||
export const useProductsActions = () => {
|
||||
const actions = useContext(ProductsActionsContext);
|
||||
|
||||
if (actions == null) {
|
||||
throw new Error(
|
||||
'useProductsActions muss innerhalb von ProductsProvider verwendet werden',
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
37
webseite-react-php-jwt/react-app/src/stores/products/api.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import { apiClient, getApiErrorMessage } from '../http';
|
||||
|
||||
export { getApiErrorMessage };
|
||||
|
||||
export const fetchProducts = async (config = {}) => {
|
||||
const { data } = await apiClient.get('/products', config);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Ungültige API-Antwort: Produktliste erwartet');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchProduct = async (id, config = {}) => {
|
||||
const { data } = await apiClient.get(`/products/${id}`, config);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createProduct = async (product) => {
|
||||
const { data } = await apiClient.post('/products', product);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateProduct = async (id, product) => {
|
||||
const { data } = await apiClient.put(`/products/${id}`, product);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const patchProduct = async (id, patch) => {
|
||||
const { data } = await apiClient.patch(`/products/${id}`, patch);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteProduct = async (id) => {
|
||||
await apiClient.delete(`/products/${id}`);
|
||||
};
|
||||
17
webseite-react-php-jwt/react-app/src/stores/products/index.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export { createInitialState } from './initialState';
|
||||
export { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
||||
export {
|
||||
ProductsProvider,
|
||||
useProductsActions,
|
||||
useProductsDispatch,
|
||||
useProductsState,
|
||||
} from './ProductsStore';
|
||||
export {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
fetchProduct,
|
||||
fetchProducts,
|
||||
getApiErrorMessage,
|
||||
patchProduct,
|
||||
updateProduct,
|
||||
} from './api';
|
||||
7
webseite-react-php-jwt/react-app/src/stores/products/initialState.js
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export const createInitialState = (items) => {
|
||||
return {
|
||||
items: items || [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
73
webseite-react-php-jwt/react-app/src/stores/products/reducer.js
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
export const PRODUCTS_ACTIONS = {
|
||||
SET_LOADING: 'SET_LOADING',
|
||||
SET_ITEMS: 'SET_ITEMS',
|
||||
SET_ITEM: 'SET_ITEM',
|
||||
SET_ERROR: 'SET_ERROR',
|
||||
ADD_ITEM: 'ADD_ITEM',
|
||||
REMOVE_ITEM: 'REMOVE_ITEM',
|
||||
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
|
||||
UPDATE_PRICE: 'UPDATE_PRICE',
|
||||
};
|
||||
|
||||
export const productsReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case PRODUCTS_ACTIONS.SET_LOADING:
|
||||
return {
|
||||
...state,
|
||||
loading: action.payload,
|
||||
error: action.payload ? null : state.error,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.SET_ITEMS:
|
||||
return {
|
||||
...state,
|
||||
items: action.payload,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.SET_ITEM:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map((item) => (item._id === action.payload._id ? action.payload : item)),
|
||||
error: null,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.SET_ERROR:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.ADD_ITEM:
|
||||
return {
|
||||
...state,
|
||||
items: [...state.items, action.payload],
|
||||
error: null,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.REMOVE_ITEM:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.filter((item) => item._id !== action.payload),
|
||||
error: null,
|
||||
};
|
||||
case PRODUCTS_ACTIONS.UPDATE_AMOUNT:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map((item) =>
|
||||
item._id === action.payload.id
|
||||
? { ...item, stock: action.payload.amount } //
|
||||
: item
|
||||
),
|
||||
};
|
||||
case PRODUCTS_ACTIONS.UPDATE_PRICE:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map((item) =>
|
||||
item._id === action.payload.id
|
||||
? { ...item, price: action.payload.price } //
|
||||
: item
|
||||
),
|
||||
};
|
||||
default:
|
||||
console.warn('no action found');
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer } from 'react';
|
||||
|
||||
import { useAuthState } from '../auth';
|
||||
import { fetchUsers, getApiErrorMessage } from './api';
|
||||
import { createInitialState } from './initialState';
|
||||
import { USERS_ACTIONS, usersReducer } from './reducer';
|
||||
|
||||
const UsersStateContext = createContext(null);
|
||||
const UsersActionsContext = createContext(null);
|
||||
|
||||
export const UsersProvider = (props) => {
|
||||
const { children, items = [], autoLoad = true } = props;
|
||||
const { token } = useAuthState();
|
||||
const [state, dispatch] = useReducer(usersReducer, items, createInitialState);
|
||||
|
||||
const loadUsers = useCallback(async (signal) => {
|
||||
dispatch({ type: USERS_ACTIONS.SET_LOADING, payload: true });
|
||||
|
||||
try {
|
||||
const loadedItems = await fetchUsers(signal ? { signal } : {});
|
||||
dispatch({ type: USERS_ACTIONS.SET_ITEMS, payload: loadedItems });
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error);
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: USERS_ACTIONS.SET_ERROR, payload: message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoLoad || !token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
loadUsers(controller.signal);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [autoLoad, token, loadUsers]);
|
||||
|
||||
const actions = useMemo(() => ({ loadUsers: () => loadUsers() }), [loadUsers]);
|
||||
|
||||
return (
|
||||
<UsersStateContext.Provider value={state}>
|
||||
<UsersActionsContext.Provider value={actions}>{children}</UsersActionsContext.Provider>
|
||||
</UsersStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUsersState = () => {
|
||||
const state = useContext(UsersStateContext);
|
||||
|
||||
if (state == null) {
|
||||
throw new Error('useUsersState muss innerhalb von UsersProvider verwendet werden');
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useUsersActions = () => {
|
||||
const actions = useContext(UsersActionsContext);
|
||||
|
||||
if (actions == null) {
|
||||
throw new Error('useUsersActions muss innerhalb von UsersProvider verwendet werden');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
13
webseite-react-php-jwt/react-app/src/stores/users/api.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import { apiClient, getApiErrorMessage } from '../http';
|
||||
|
||||
export { getApiErrorMessage };
|
||||
|
||||
export const fetchUsers = async (config = {}) => {
|
||||
const { data } = await apiClient.get('/users', config);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Ungültige API-Antwort: Benutzerliste erwartet');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
4
webseite-react-php-jwt/react-app/src/stores/users/index.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createInitialState } from './initialState';
|
||||
export { USERS_ACTIONS, usersReducer } from './reducer';
|
||||
export { UsersProvider, useUsersActions, useUsersState } from './UsersStore';
|
||||
export { fetchUsers, getApiErrorMessage } from './api';
|
||||