build app

This commit is contained in:
Marc-Alexander Iten 2026-05-14 12:40:21 +02:00
parent d2921d9222
commit 23b9c34357
15 changed files with 2768 additions and 0 deletions

126
README.md
View File

@ -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

File diff suppressed because one or more lines are too long

1
dist/assets/index-DhKmTnbd.css vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View 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
View 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
View 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
}

View 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

View 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

View 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
View 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
View 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
View 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()],
})