build app
This commit is contained in:
parent
d2921d9222
commit
23b9c34357
126
README.md
126
README.md
@ -0,0 +1,126 @@
|
|||||||
|
# ToDo-App – React Beispiel
|
||||||
|
|
||||||
|
Einfache ToDo-Web-App für den Frontend-Unterricht.
|
||||||
|
Gebaut mit **React 18** und **Vite** – läuft vollständig im Browser, kein eigener Server nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
WebDev-React-ToDo-Example/
|
||||||
|
├── index.html # HTML-Einstiegspunkt (Root-Element für React)
|
||||||
|
├── vite.config.js # Vite Build-Tool Konfiguration
|
||||||
|
├── package.json # Abhängigkeiten und npm-Scripts
|
||||||
|
└── src/
|
||||||
|
├── main.jsx # React-Einstiegspunkt (createRoot)
|
||||||
|
├── App.jsx # Haupt-Komponente (State, Effekte, Handler)
|
||||||
|
├── api.js # API-Service (alle fetch()-Aufrufe)
|
||||||
|
├── index.css # Globales Stylesheet (kein Framework)
|
||||||
|
└── components/
|
||||||
|
├── TodoForm.jsx # Formular: neues ToDo erstellen
|
||||||
|
├── TodoList.jsx # Liste + Pagination
|
||||||
|
└── TodoItem.jsx # Einzelnes ToDo (Checkbox, Löschen)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Abhängigkeiten installieren
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Entwicklungsserver starten (mit Hot Reload)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 3. Produktions-Build erstellen
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach `npm run dev` ist die App unter **http://localhost:5173** erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendete Technologien
|
||||||
|
|
||||||
|
| Technologie | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| [React 18](https://react.dev) | UI-Bibliothek (Komponenten, State, Hooks) |
|
||||||
|
| [Vite](https://vitejs.dev) | Build-Tool & Dev-Server |
|
||||||
|
| Fetch API (Browser built-in) | HTTP-Anfragen an die REST-API |
|
||||||
|
| CSS (vanilla) | Styling ohne externe Bibliotheken |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die App kommuniziert mit der REST-API unter:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://webdev.iten-web.ch/10003/api/
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App verwendet **Best-Practice-Routing mit REST-Pfaden**.
|
||||||
|
|
||||||
|
| Methode | URL | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/todos?page=1&limit=10` | Liste abrufen (paginiert) |
|
||||||
|
| POST | `/todos` | Neues ToDo erstellen |
|
||||||
|
| PATCH | `/todos/{id}` | Felder teilweise aktualisieren |
|
||||||
|
| DELETE | `/todos/{id}` | ToDo löschen |
|
||||||
|
|
||||||
|
Alle API-Aufrufe sind in `src/api.js` gekapselt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automatisches Aktualisieren (Polling)
|
||||||
|
|
||||||
|
Die App lädt die aktuell sichtbare Seite **alle 5 Sekunden** automatisch neu.
|
||||||
|
|
||||||
|
- Intervall: `5000 ms` in `src/App.jsx`
|
||||||
|
- Technik: `setInterval(...)` in einem `useEffect(...)`
|
||||||
|
- Cleanup: `clearInterval(...)`, damit beim Unmount keine Hintergrund-Timer aktiv bleiben
|
||||||
|
|
||||||
|
Damit unnötige Re-Renders vermieden werden, vergleicht die App die neu geladenen ToDos
|
||||||
|
mit den bereits angezeigten Daten (`areTodosEqual`).
|
||||||
|
|
||||||
|
- **Keine Änderung**: State bleibt unverändert, DOM bleibt wie es ist
|
||||||
|
- **Änderung vorhanden**: State wird aktualisiert, React rendert die betroffenen Elemente neu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige React-Konzepte im Code
|
||||||
|
|
||||||
|
### Komponenten
|
||||||
|
Jede `.jsx`-Datei exportiert eine Funktion, die JSX zurückgibt.
|
||||||
|
JSX ist eine HTML-ähnliche Syntax, die zu `React.createElement()`-Aufrufen kompiliert wird.
|
||||||
|
|
||||||
|
### Props
|
||||||
|
Daten fliessen von Eltern- zu Kindkomponenten über Props (Parameter der Funktion):
|
||||||
|
```jsx
|
||||||
|
<TodoItem todo={todo} onDelete={handleDelete} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### State (`useState`)
|
||||||
|
Lokaler Zustand einer Komponente. Ändert sich der State, rendert React die Komponente neu:
|
||||||
|
```jsx
|
||||||
|
const [todos, setTodos] = useState([])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effekte (`useEffect`)
|
||||||
|
Seiteneffekte (z.B. API-Aufruf) nach dem Render ausführen:
|
||||||
|
```jsx
|
||||||
|
useEffect(() => {
|
||||||
|
loadTodos()
|
||||||
|
}, [page]) // läuft neu, wenn sich `page` ändert
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenfluss
|
||||||
|
```
|
||||||
|
App (State)
|
||||||
|
↓ Props
|
||||||
|
TodoList → TodoItem
|
||||||
|
↑ Callbacks (onToggle, onDelete)
|
||||||
|
App (State aktualisieren)
|
||||||
|
```
|
||||||
40
dist/assets/index-BLMhEhhy.js
vendored
Normal file
40
dist/assets/index-BLMhEhhy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DhKmTnbd.css
vendored
Normal file
1
dist/assets/index-DhKmTnbd.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5;color:#222;padding:2rem 1rem;min-height:100vh}.container{max-width:640px;margin:0 auto;background:#fff;border-radius:10px;padding:2rem;box-shadow:0 2px 12px #00000014}h1{font-size:1.9rem;margin-bottom:.25rem}.subtitle{color:#888;margin-bottom:1.75rem;font-size:.875rem}.todo-form{display:flex;gap:.5rem;margin-bottom:1.5rem}.input-userid{width:72px;padding:.5rem .6rem;border:1px solid #ccc;border-radius:6px;font-size:1rem}.input-title{flex:1;padding:.5rem .75rem;border:1px solid #ccc;border-radius:6px;font-size:1rem}.input-userid:focus,.input-title:focus{outline:none;border-color:#4a90e2;box-shadow:0 0 0 3px #4a90e226}button{padding:.5rem 1.1rem;border:none;border-radius:6px;background:#4a90e2;color:#fff;cursor:pointer;font-size:1rem;transition:background .15s}button:hover:not(:disabled){background:#357abd}button:disabled{background:#ccc;cursor:not-allowed}.todo-list{list-style:none}.todo-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 0;border-bottom:1px solid #f0f0f0}.todo-item:last-child{border-bottom:none}.todo-item.completed .todo-title{text-decoration:line-through;color:#aaa}.todo-title{flex:1;font-size:.95rem}.todo-meta{font-size:.78rem;color:#bbb;white-space:nowrap}.btn-delete{background:#e74c3c;padding:.3rem .65rem;font-size:.8rem;border-radius:4px;flex-shrink:0}.btn-delete:hover:not(:disabled){background:#c0392b}.pagination{display:flex;align-items:center;justify-content:center;gap:1.25rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #f0f0f0}.pagination span{font-size:.9rem;color:#666}.loading,.empty{text-align:center;color:#999;padding:2.5rem 0;font-size:.95rem}.error{color:#c0392b;background:#fdf0ef;border:1px solid #f5c6c2;border-radius:6px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.9rem}
|
||||||
22
dist/index.html
vendored
Normal file
22
dist/index.html
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ToDo-App – React Beispiel</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BLMhEhhy.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DhKmTnbd.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
Einstiegspunkt der React-App.
|
||||||
|
React rendert die gesamte Benutzeroberfläche in dieses <div>.
|
||||||
|
-->
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Das Vite Build-Tool verarbeitet dieses Skript-Tag.
|
||||||
|
main.jsx ist der JavaScript-Einstiegspunkt.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
index.html
Normal file
21
index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ToDo-App – React Beispiel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
Einstiegspunkt der React-App.
|
||||||
|
React rendert die gesamte Benutzeroberfläche in dieses <div>.
|
||||||
|
-->
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Das Vite Build-Tool verarbeitet dieses Skript-Tag.
|
||||||
|
main.jsx ist der JavaScript-Einstiegspunkt.
|
||||||
|
-->
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1716
package-lock.json
generated
Normal file
1716
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "webdev-react-todo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/App.jsx
Normal file
243
src/App.jsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* App.jsx – Haupt-Komponente der ToDo-App
|
||||||
|
* =========================================
|
||||||
|
*
|
||||||
|
* Diese Komponente ist die "Schaltzentrale" der Anwendung.
|
||||||
|
* Sie ist verantwortlich für:
|
||||||
|
*
|
||||||
|
* 1. STATE verwalten: Welche Daten existieren und wie ist der aktuelle Zustand?
|
||||||
|
* 2. EFFEKTE auslösen: Wann sollen Daten von der API geladen werden?
|
||||||
|
* 3. HANDLER definieren: Was passiert bei Benutzeraktionen (Hinzufügen, Löschen …)?
|
||||||
|
* 4. RENDER: Kindkomponenten mit Daten und Callbacks versorgen.
|
||||||
|
*
|
||||||
|
* Datenfluss in React:
|
||||||
|
* App (State) → Props → Kindkomponenten
|
||||||
|
* Kindkomponenten → Callbacks → App (State ändern)
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { fetchTodos, createTodo, patchTodo, deleteTodo } from './api'
|
||||||
|
import TodoForm from './components/TodoForm'
|
||||||
|
import TodoList from './components/TodoList'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
/** Anzahl ToDos pro Seite (an API und Pagination-Logik weitergegeben) */
|
||||||
|
const LIMIT = 10
|
||||||
|
|
||||||
|
/** Polling-Intervall in Millisekunden (5000 ms = 5 Sekunden) */
|
||||||
|
const POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleicht zwei Todo-Arrays auf inhaltliche Gleichheit.
|
||||||
|
*
|
||||||
|
* Wenn sich nichts geändert hat, behalten wir den bisherigen State bei.
|
||||||
|
* Dadurch vermeiden wir unnötige Re-Renders und React muss das DOM nicht neu anpassen.
|
||||||
|
*/
|
||||||
|
function areTodosEqual(listA, listB) {
|
||||||
|
if (listA.length !== listB.length) return false
|
||||||
|
|
||||||
|
for (let i = 0; i < listA.length; i += 1) {
|
||||||
|
const a = listA[i]
|
||||||
|
const b = listB[i]
|
||||||
|
|
||||||
|
if (
|
||||||
|
a.id !== b.id ||
|
||||||
|
a.userId !== b.userId ||
|
||||||
|
a.title !== b.title ||
|
||||||
|
a.completed !== b.completed
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// ==========================================================
|
||||||
|
// STATE
|
||||||
|
// ==========================================================
|
||||||
|
// useState(initialWert) → [aktueller Wert, Setter-Funktion]
|
||||||
|
// Beim Aufruf des Setters rendert React die Komponente neu.
|
||||||
|
|
||||||
|
/** Array der aktuell angezeigten ToDo-Objekte */
|
||||||
|
const [todos, setTodos] = useState([])
|
||||||
|
|
||||||
|
/** Aktuelle Seitennummer für die Pagination (beginnt bei 1) */
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
/** true, solange eine API-Anfrage läuft */
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
/** Fehlermeldung als String, oder null wenn kein Fehler */
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// Zentrale Ladefunktion
|
||||||
|
// ==========================================================
|
||||||
|
/**
|
||||||
|
* Lädt die aktuelle Seite von der API.
|
||||||
|
* showLoading=true zeigt den Ladezustand (für initiales Laden / Seitenwechsel),
|
||||||
|
* beim Polling lassen wir den Ladezustand aus, damit die UI nicht flackert.
|
||||||
|
*/
|
||||||
|
const loadTodos = useCallback(async ({ showLoading = false } = {}) => {
|
||||||
|
if (showLoading) setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchTodos(page, LIMIT)
|
||||||
|
|
||||||
|
// Vergleich mit aktuell dargestellten Daten (virtuelles DOM / State).
|
||||||
|
// Nur wenn sich Inhalte unterscheiden, wird der State ersetzt.
|
||||||
|
setTodos((prev) => (areTodosEqual(prev, data) ? prev : data))
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// EFFEKT 1: Initiales Laden + Laden bei Seitenwechsel
|
||||||
|
// ==========================================================
|
||||||
|
useEffect(() => {
|
||||||
|
loadTodos({ showLoading: true })
|
||||||
|
}, [loadTodos])
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// EFFEKT 2: Polling alle 5 Sekunden
|
||||||
|
// ==========================================================
|
||||||
|
/**
|
||||||
|
* Lädt periodisch nach und vergleicht mit dem aktuellen State.
|
||||||
|
* Änderungen am Backend werden dadurch zeitnah in der UI sichtbar.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
loadTodos()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
// Cleanup verhindert Memory-Leaks beim Unmount oder Seitenwechsel.
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}, [loadTodos])
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// HANDLER
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handleAdd – Neues ToDo über die API erstellen (POST).
|
||||||
|
* Wird als onAdd-Prop an <TodoForm> weitergegeben.
|
||||||
|
*
|
||||||
|
* Nach dem Erstellen wird die Seite neu geladen, indem wir
|
||||||
|
* auf Seite 1 zurückspringen (löst den useEffect erneut aus).
|
||||||
|
*
|
||||||
|
* @param {{ userId: number, title: string }} newTodo
|
||||||
|
*/
|
||||||
|
async function handleAdd(newTodo) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createTodo(newTodo)
|
||||||
|
// Zurück auf Seite 1. Falls wir bereits auf Seite 1 sind,
|
||||||
|
// laden wir die Daten direkt neu.
|
||||||
|
if (page === 1) {
|
||||||
|
await loadTodos({ showLoading: true })
|
||||||
|
} else {
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handleToggle – completed-Status eines ToDos umschalten (PATCH).
|
||||||
|
* Wird als onToggle-Prop an <TodoList> → <TodoItem> weitergegeben.
|
||||||
|
*
|
||||||
|
* Statt die ganze Liste neu zu laden, aktualisieren wir nur das
|
||||||
|
* betroffene Element im lokalen State (optimistic / lokal).
|
||||||
|
*
|
||||||
|
* @param {number} id - ID des ToDos
|
||||||
|
* @param {boolean} completed - Neuer Status
|
||||||
|
*/
|
||||||
|
async function handleToggle(id, completed) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const updated = await patchTodo(id, { completed })
|
||||||
|
// todos.map() erstellt ein NEUES Array (State darf nie direkt mutiert werden!)
|
||||||
|
// Nur das geänderte Element wird durch das aktualisierte ersetzt.
|
||||||
|
setTodos((prev) =>
|
||||||
|
prev.map((t) => (t.id === id ? { ...t, completed: updated.completed } : t))
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handleDelete – ToDo über die API löschen (DELETE).
|
||||||
|
* Wird als onDelete-Prop an <TodoList> → <TodoItem> weitergegeben.
|
||||||
|
*
|
||||||
|
* Nach erfolgreichem Löschen wird das Element lokal aus dem State entfernt.
|
||||||
|
*
|
||||||
|
* @param {number} id - ID des zu löschenden ToDos
|
||||||
|
*/
|
||||||
|
async function handleDelete(id) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await deleteTodo(id)
|
||||||
|
// filter() erstellt ein neues Array ohne das gelöschte Element
|
||||||
|
setTodos((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pagination-Handler ---
|
||||||
|
|
||||||
|
/** Eine Seite zurück (Minimum: Seite 1) */
|
||||||
|
function handlePrev() {
|
||||||
|
setPage((p) => Math.max(1, p - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Seite vor */
|
||||||
|
function handleNext() {
|
||||||
|
setPage((p) => p + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// RENDER
|
||||||
|
// ==========================================================
|
||||||
|
// JSX: HTML-ähnliche Syntax, die Vite/Babel zu React.createElement() kompiliert.
|
||||||
|
// Ausdrücke in { } werden als JavaScript ausgewertet.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>ToDo-App</h1>
|
||||||
|
<p className="subtitle">React + Vite · REST API Demo</p>
|
||||||
|
|
||||||
|
{/* Formular zum Erstellen eines neuen ToDos */}
|
||||||
|
<TodoForm onAdd={handleAdd} />
|
||||||
|
|
||||||
|
{/* Fehlermeldung anzeigen (nur wenn error nicht null ist) */}
|
||||||
|
{error && <p className="error">⚠ {error}</p>}
|
||||||
|
|
||||||
|
{/* Ladeindikator ODER die Liste anzeigen */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="loading">Lädt …</p>
|
||||||
|
) : (
|
||||||
|
<TodoList
|
||||||
|
todos={todos}
|
||||||
|
page={page}
|
||||||
|
// "hasMore": Wenn wir genau LIMIT Einträge bekommen haben,
|
||||||
|
// gibt es vermutlich noch eine nächste Seite.
|
||||||
|
hasMore={todos.length === LIMIT}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
203
src/api.js
Normal file
203
src/api.js
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* api.js – Zentraler Service für alle API-Aufrufe
|
||||||
|
* ================================================
|
||||||
|
*
|
||||||
|
* Dieses Modul kapselt die gesamte Kommunikation mit der REST-API.
|
||||||
|
* Die UI-Komponenten importieren nur die benötigten Funktionen –
|
||||||
|
* sie müssen nicht wissen, wie die Fetch-Aufrufe intern funktionieren.
|
||||||
|
*
|
||||||
|
* API-Basis: https://webdev.iten-web.ch/10003/api
|
||||||
|
*
|
||||||
|
* Best-Practice-Routing mit REST-Pfaden:
|
||||||
|
* GET https://webdev.iten-web.ch/10003/api/todos?page=1&limit=10
|
||||||
|
* GET https://webdev.iten-web.ch/10003/api/todos/42
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Basis-URL der API – hier zentral änderbar */
|
||||||
|
const BASE_URL = 'https://webdev.iten-web.ch/10003/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildUrl – Erstellt eine vollständige Anfrage-URL.
|
||||||
|
*
|
||||||
|
* @param {string} path - API-Pfad, z.B. "/todos" oder "/todos/5"
|
||||||
|
* @param {object} [params] - Optionale Query-Parameter als Schlüssel-Wert-Objekt
|
||||||
|
* @returns {string} - Fertige URL als String
|
||||||
|
*
|
||||||
|
* Beispiel:
|
||||||
|
* buildUrl('/todos', { page: 2, limit: 10 })
|
||||||
|
* → "https://webdev.iten-web.ch/10003/api/todos?page=2&limit=10"
|
||||||
|
*/
|
||||||
|
function buildUrl(path, params = {}) {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
const url = new URL(`${BASE_URL}${normalizedPath}`)
|
||||||
|
|
||||||
|
// Weitere Parameter anhängen (leere Werte werden übersprungen)
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
url.searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// GET /todos – Liste abrufen (mit Pagination)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetchTodos – Ruft eine paginierte Liste von ToDos ab.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Seitennummer (1-basiert)
|
||||||
|
* @param {number} [limit=10] - Anzahl Einträge pro Seite
|
||||||
|
* @returns {Promise<Array>} - Array von Todo-Objekten
|
||||||
|
*
|
||||||
|
* Todo-Objekt: { id, userId, title, completed }
|
||||||
|
*/
|
||||||
|
export async function fetchTodos(page = 1, limit = 10) {
|
||||||
|
const url = buildUrl('/todos', { page, limit })
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
// HTTP-Fehler (4xx, 5xx) werden von fetch() NICHT automatisch geworfen –
|
||||||
|
// wir prüfen response.ok manuell und werfen ggf. einen Fehler
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler beim Laden der ToDos (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// response.json() liest den Response-Body und parst ihn als JSON
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// GET /todos/{id} – Einzelnes ToDo abrufen
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetchTodoById – Ruft ein einzelnes ToDo per ID ab.
|
||||||
|
*
|
||||||
|
* @param {number} id - ID des gesuchten ToDos
|
||||||
|
* @returns {Promise<object>} - Todo-Objekt
|
||||||
|
*/
|
||||||
|
export async function fetchTodoById(id) {
|
||||||
|
const url = buildUrl(`/todos/${id}`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`ToDo #${id} nicht gefunden (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// POST /todos – Neues ToDo erstellen
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createTodo – Erstellt ein neues ToDo.
|
||||||
|
*
|
||||||
|
* @param {{ userId: number, title: string, completed?: boolean }} todo
|
||||||
|
* @returns {Promise<object>} - Erstelltes ToDo-Objekt (inkl. vergebener id)
|
||||||
|
*
|
||||||
|
* Beispiel-Aufruf:
|
||||||
|
* createTodo({ userId: 1, title: "Einkaufen gehen" })
|
||||||
|
*/
|
||||||
|
export async function createTodo(todo) {
|
||||||
|
const url = buildUrl('/todos')
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
// Teilt dem Server mit, dass wir JSON senden
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
// JSON.stringify() wandelt das JavaScript-Objekt in einen JSON-String um
|
||||||
|
body: JSON.stringify(todo),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler beim Erstellen des ToDos (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Server antwortet mit 201 Created + dem neuen Objekt
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// PATCH /todos/{id} – ToDo teilweise aktualisieren
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* patchTodo – Aktualisiert einzelne Felder eines ToDos.
|
||||||
|
*
|
||||||
|
* Im Unterschied zu PUT werden bei PATCH nur die angegebenen Felder
|
||||||
|
* geändert – alle anderen bleiben unverändert.
|
||||||
|
*
|
||||||
|
* @param {number} id - Todo-ID
|
||||||
|
* @param {{ userId?: number, title?: string, completed?: boolean }} changes
|
||||||
|
* @returns {Promise<object>} - Aktualisiertes ToDo
|
||||||
|
*
|
||||||
|
* Beispiel-Aufruf (nur completed ändern):
|
||||||
|
* patchTodo(5, { completed: true })
|
||||||
|
*/
|
||||||
|
export async function patchTodo(id, changes) {
|
||||||
|
const url = buildUrl(`/todos/${id}`)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler beim Aktualisieren von ToDo #${id} (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// PUT /todos/{id} – ToDo vollständig ersetzen
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* putTodo – Ersetzt ein ToDo vollständig (alle Felder müssen angegeben werden).
|
||||||
|
*
|
||||||
|
* @param {number} id - Todo-ID
|
||||||
|
* @param {{ userId: number, title: string, completed: boolean }} todo
|
||||||
|
* @returns {Promise<object>} - Ersetztes ToDo
|
||||||
|
*/
|
||||||
|
export async function putTodo(id, todo) {
|
||||||
|
const url = buildUrl(`/todos/${id}`)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(todo),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler beim Ersetzen von ToDo #${id} (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// DELETE /todos/{id} – ToDo löschen
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleteTodo – Löscht ein ToDo anhand seiner ID.
|
||||||
|
*
|
||||||
|
* @param {number} id - Todo-ID
|
||||||
|
* @returns {Promise<void>} - Kein Rückgabewert (204 No Content)
|
||||||
|
*/
|
||||||
|
export async function deleteTodo(id) {
|
||||||
|
const url = buildUrl(`/todos/${id}`)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler beim Löschen von ToDo #${id} (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
// DELETE gibt HTTP 204 No Content zurück → kein response.json() nötig
|
||||||
|
}
|
||||||
66
src/components/TodoForm.jsx
Normal file
66
src/components/TodoForm.jsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* TodoForm.jsx – Formular zum Erstellen eines neuen ToDos
|
||||||
|
* =========================================================
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* onAdd(todo) – Callback-Funktion, die mit dem neuen Todo-Objekt
|
||||||
|
* aufgerufen wird, sobald das Formular abgeschickt wird.
|
||||||
|
* Das Todo-Objekt hat die Form: { userId, title }
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
function TodoForm({ onAdd }) {
|
||||||
|
// --- Lokaler State der Formularfelder ---
|
||||||
|
// useState gibt [aktueller Wert, Setter-Funktion] zurück.
|
||||||
|
// Jedes Mal wenn der Setter aufgerufen wird, rendert React die Komponente neu.
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [userId, setUserId] = useState(1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handleSubmit – wird beim Klick auf "Hinzufügen" / Enter ausgeführt.
|
||||||
|
*
|
||||||
|
* @param {Event} e - Das Browser-Formular-Event
|
||||||
|
*/
|
||||||
|
function handleSubmit(e) {
|
||||||
|
// Verhindert das Standard-Verhalten des Browsers (Seite neu laden)
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Leere Titel ignorieren (trim() entfernt Leerzeichen am Rand)
|
||||||
|
if (!title.trim()) return
|
||||||
|
|
||||||
|
// Callback an die Elternkomponente (App.jsx) aufrufen
|
||||||
|
onAdd({ userId: Number(userId), title: title.trim() })
|
||||||
|
|
||||||
|
// Eingabefeld zurücksetzen
|
||||||
|
setTitle('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="todo-form">
|
||||||
|
{/* Zahlenfeld für die User-ID */}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={userId}
|
||||||
|
min="1"
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
className="input-userid"
|
||||||
|
aria-label="User-ID"
|
||||||
|
title="User-ID"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Textfeld für den Titel – "controlled input": Wert kommt aus State */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Neues ToDo eingeben ..."
|
||||||
|
className="input-title"
|
||||||
|
aria-label="Titel des ToDos"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodoForm
|
||||||
44
src/components/TodoItem.jsx
Normal file
44
src/components/TodoItem.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* TodoItem.jsx – Einzelnes ToDo in der Liste
|
||||||
|
* ===========================================
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* todo – Todo-Objekt: { id, userId, title, completed }
|
||||||
|
* onToggle(id, completed) – Callback: completed-Status umschalten
|
||||||
|
* onDelete(id) – Callback: ToDo löschen
|
||||||
|
*/
|
||||||
|
|
||||||
|
function TodoItem({ todo, onToggle, onDelete }) {
|
||||||
|
return (
|
||||||
|
// Klasse "completed" wird gesetzt, wenn todo.completed === true
|
||||||
|
// → CSS kann erledigte Todos anders darstellen (durchgestrichen)
|
||||||
|
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
|
||||||
|
|
||||||
|
{/* Checkbox: Haken = erledigt, kein Haken = offen */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={todo.completed}
|
||||||
|
// Bei Klick: Callback mit umgekehrtem Status aufrufen
|
||||||
|
onChange={() => onToggle(todo.id, !todo.completed)}
|
||||||
|
aria-label="Status umschalten"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Titel des ToDos */}
|
||||||
|
<span className="todo-title">{todo.title}</span>
|
||||||
|
|
||||||
|
{/* Kleine Anzeige: welchem User das ToDo gehört */}
|
||||||
|
<span className="todo-meta">User {todo.userId}</span>
|
||||||
|
|
||||||
|
{/* Löschen-Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(todo.id)}
|
||||||
|
className="btn-delete"
|
||||||
|
aria-label={`ToDo "${todo.title}" löschen`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodoItem
|
||||||
59
src/components/TodoList.jsx
Normal file
59
src/components/TodoList.jsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* TodoList.jsx – Liste aller ToDos + Pagination
|
||||||
|
* ===============================================
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* todos – Array von Todo-Objekten
|
||||||
|
* page – Aktuelle Seitennummer (Zahl)
|
||||||
|
* hasMore – true, wenn es eine nächste Seite geben könnte
|
||||||
|
* onPrev() – Callback: eine Seite zurück
|
||||||
|
* onNext() – Callback: eine Seite vor
|
||||||
|
* onToggle – Wird an TodoItem weitergeleitet
|
||||||
|
* onDelete – Wird an TodoItem weitergeleitet
|
||||||
|
*/
|
||||||
|
import TodoItem from './TodoItem'
|
||||||
|
|
||||||
|
function TodoList({ todos, page, hasMore, onPrev, onNext, onToggle, onDelete }) {
|
||||||
|
// Sonderfall: Keine Ergebnisse
|
||||||
|
if (todos.length === 0) {
|
||||||
|
return <p className="empty">Keine ToDos gefunden.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Die eigentliche Liste – todos.map() erzeugt für jedes Element ein <TodoItem> */}
|
||||||
|
{/*
|
||||||
|
key={todo.id} ist PFLICHT bei Listen in React:
|
||||||
|
React nutzt den Key, um bei Updates effizient die richtigen
|
||||||
|
DOM-Elemente zu finden und zu aktualisieren.
|
||||||
|
*/}
|
||||||
|
<ul className="todo-list">
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<TodoItem
|
||||||
|
key={todo.id}
|
||||||
|
todo={todo}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Pagination: Zurück / Seite X / Weiter */}
|
||||||
|
<div className="pagination">
|
||||||
|
{/* disabled wenn wir auf Seite 1 sind */}
|
||||||
|
<button onClick={onPrev} disabled={page <= 1}>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span>Seite {page}</span>
|
||||||
|
|
||||||
|
{/* disabled wenn die letzte Seite weniger Einträge hatte als das Limit */}
|
||||||
|
<button onClick={onNext} disabled={!hasMore}>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodoList
|
||||||
181
src/index.css
Normal file
181
src/index.css
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
/* =============================================================
|
||||||
|
index.css – Globales Stylesheet der ToDo-App
|
||||||
|
Kein externes CSS-Framework – alles von Hand geschrieben.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* --- Reset & Basis ----------------------------------------- */
|
||||||
|
/* Box-Sizing: border-box → Padding/Border werden NICHT zur Breite addiert */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #222;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Container --------------------------------------------- */
|
||||||
|
.container {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Formular ---------------------------------------------- */
|
||||||
|
.todo-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schmales Zahlenfeld für die User-ID */
|
||||||
|
.input-userid {
|
||||||
|
width: 72px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breites Textfeld für den Titel – wächst im Flexbox-Container */
|
||||||
|
.input-title {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-userid:focus,
|
||||||
|
.input-title:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4a90e2;
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons ----------------------------------------------- */
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #4a90e2;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #357abd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ToDo-Liste -------------------------------------------- */
|
||||||
|
.todo-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jede Zeile */
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erledigte Todos werden durchgestrichen und abgeschwächt */
|
||||||
|
.todo-item.completed .todo-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kleine Anzeige der User-ID */
|
||||||
|
.todo-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #bbb;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Löschen-Button (rot) */
|
||||||
|
.btn-delete {
|
||||||
|
background: #e74c3c;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover:not(:disabled) {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pagination -------------------------------------------- */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zustands-Meldungen ------------------------------------ */
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fehlermeldung (rotes Banner) */
|
||||||
|
.error {
|
||||||
|
color: #c0392b;
|
||||||
|
background: #fdf0ef;
|
||||||
|
border: 1px solid #f5c6c2;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
17
src/main.jsx
Normal file
17
src/main.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* main.jsx – Einstiegspunkt der React-Anwendung
|
||||||
|
*
|
||||||
|
* createRoot() bindet React an das <div id="root"> in der index.html.
|
||||||
|
* StrictMode aktiviert zusätzliche Warnungen in der Entwicklung –
|
||||||
|
* er führt bestimmte Funktionen doppelt aus, um Fehler früh zu erkennen.
|
||||||
|
*/
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// vite.config.js – Konfiguration des Vite Build-Tools
|
||||||
|
//
|
||||||
|
// @vitejs/plugin-react aktiviert die React-spezifischen Transformationen:
|
||||||
|
// - JSX wird zu JavaScript kompiliert
|
||||||
|
// - Hot Module Replacement (HMR) für schnelles Entwickeln
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user