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