build app

This commit is contained in:
Marc-Alexander Iten 2026-05-14 12:40:09 +02:00
parent 42fcb8f645
commit 4fe9cb9be4
8 changed files with 2305 additions and 0 deletions

View File

@ -0,0 +1,97 @@
# ToDo-App Vue 3 + Vite
Unterrichtsbeispiel für **Web Development** an der FHGR.
Eine einfache ToDo-Anwendung mit Vue 3, Vite und einer REST-API.
## Projektstruktur
```
WebDev-VueJS-ToDo-Example/
├── index.html # HTML-Einstiegspunkt (enthält <div id="app">)
├── vite.config.js # Vite-Konfiguration (aktiviert Vue-Plugin)
├── package.json # Abhängigkeiten und npm-Skripte
└── src/
├── main.js # Vue-App erstellen und mounten
├── api.js # API-Schicht: alle HTTP-Aufrufe (fetch)
└── App.vue # Haupt-Komponente: State, Logik und Template
```
## Verwendete Technologien
| Technologie | Zweck |
|---|---|
| **Vue 3** | Reaktives UI-Framework |
| **Vite** | Build-Tool und Dev-Server |
| **Fetch API** | HTTP-Anfragen (nativ im Browser, kein axios nötig) |
| **CSS Custom Properties** | Design ohne externe Libraries |
## Starten
```bash
npm install # Abhängigkeiten installieren
npm run dev # Entwicklungsserver starten (http://localhost:5173)
npm run build # Produktionsbuild erstellen (→ dist/)
npm run preview # Produktionsbuild lokal testen
```
## Lernziele / Vue-Konzepte
### Reaktivität
- `ref()` reaktiver Einzelwert
- `computed()` abgeleiteter, gecachter Wert
- `watch()` Reaktion auf Änderungen einer Variable
### Template-Syntax
- `{{ }}` Textinterpolation
- `v-if` / `v-else` bedingtes Rendern
- `v-for` + `:key` Listen rendern
- `v-model` Zwei-Wege-Datenbindung (Input ↔ Variable)
- `:class` dynamische CSS-Klassen
- `@click`, `@submit.prevent` Event-Handler
### Lifecycle
- `onMounted()` Code nach dem ersten Rendern ausführen
- `onUnmounted()` Aufräumen (z.B. `setInterval` stoppen)
### Architektur
- Trennung von **API-Schicht** (`api.js`) und **UI-Logik** (`App.vue`)
- Alle HTTP-Methoden: `GET`, `POST`, `PATCH`, `DELETE`
- Auto-Synchronisation im 5-Sekunden-Takt mit Datenvergleich
## Auto-Synchronisation (5 Sekunden)
Die App lädt alle 5 Sekunden die aktuell sichtbaren ToDos neu und vergleicht sie
mit der bereits gerenderten Liste.
Verglichen werden pro ToDo:
- `id` (neu/gelöscht)
- `title`, `completed`, `userId` (geändert)
Wenn Unterschiede erkannt werden, wird die Liste ersetzt und Vue aktualisiert
das DOM automatisch.
Wenn keine Unterschiede erkannt werden, bleibt der DOM unverändert.
Im UI zeigt eine Statuszeile den letzten Synchronisationszeitpunkt und das
Ergebnis des Vergleichs (neu/geändert/gelöscht oder keine Änderung).
## API
Basis-URL: `https://webdev.iten-web.ch/10003/api/`
Die API verwendet REST-Pfad-Routing (Best Practice):
```
GET /10003/api/todos → alle ToDos
GET /10003/api/todos/1 → einzelnes ToDo
POST /10003/api/todos → neues ToDo erstellen
PATCH /10003/api/todos/1 → Status aktualisieren
DELETE /10003/api/todos/1 → ToDo löschen
```
Verfügbare Filter-Parameter:
- `page`, `limit` Pagination
- `search` Suche im Titel
- `completed` Filter: `true` / `false`
- `sortBy` Sortierfeld: `id`, `title`, `completed`
- `sortDir` Richtung: `ASC`, `DESC`

22
index.html 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 VueJS Beispiel</title>
</head>
<body>
<!--
Der Einstiegspunkt der Vue-App.
Vue ersetzt dieses <div id="app"> mit dem gerenderten Inhalt
der Root-Komponente (App.vue).
-->
<div id="app"></div>
<!--
Das Skript wird als ES-Modul geladen (type="module").
Vite verarbeitet src/main.js und alle importierten Dateien.
-->
<script type="module" src="/src/main.js"></script>
</body>
</html>

1246
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "webdev-vuejs-todo-example",
"version": "1.0.0",
"description": "ToDo-App mit Vue 3 und Vite Unterrichtsbeispiel FHGR",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

763
src/App.vue Normal file
View File

@ -0,0 +1,763 @@
<script setup>
/**
* App.vue Haupt- und einzige Komponente der ToDo-App
*
* Vue 3 Composition API mit <script setup>
*
* <script setup> ist der moderne, kompakte Weg, Vue-Komponenten zu schreiben.
* Alles, was hier deklariert wird (ref, computed, Funktionen), ist direkt
* im Template verfügbar ohne explizites "return {}".
*
* Verwendete Vue-Konzepte:
* ref() reaktiver Einzelwert (Primitive oder Objekt)
* computed() abgeleiteter Wert, wird bei Änderungen neu berechnet
* onMounted() Lifecycle-Hook: wird nach dem ersten Rendern aufgerufen
* watch() Beobachtet eine reaktive Variable und reagiert auf Änderungen
*/
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { fetchTodos, createTodo, patchTodo, deleteTodo } from './api.js'
//
// STATE reaktive Zustandsvariablen
// ref() erstellt ein reaktives Objekt mit einer .value-Eigenschaft.
// Im Template wird .value automatisch ausgepackt (kein .value nötig).
//
// Liste der aktuell angezeigten ToDos (Array von Todo-Objekten)
const todos = ref([])
// Ladezustand true während ein API-Aufruf läuft
const loading = ref(false)
// Fehlermeldung null wenn kein Fehler, sonst Fehlertext als String
const error = ref(null)
// Pagination: aktuelle Seite und Einträge pro Seite
const page = ref(1)
const limit = ref(10)
// Filter- und Suchoptionen
const search = ref('') // Suche im Titel
const filterCompleted = ref('') // '' = alle | 'true' = erledigt | 'false' = offen
// Sortierung
const sortBy = ref('id') // Sortierfeld: 'id' | 'title' | 'completed'
const sortDir = ref('ASC') // Richtung: 'ASC' | 'DESC'
// Formular für neues ToDo
const newTitle = ref('') // Eingabe: Titel des neuen ToDos
const newUserId = ref(1) // Eingabe: User-ID (110)
const formError = ref(null) // Fehlermeldung im Formular
// Auto-Synchronisation (Polling)
const syncIntervalMs = 5000
const syncStatus = ref('Noch nicht synchronisiert')
let pollTimerId = null
let requestInFlight = false
//
// COMPUTED abgeleitete Werte
// computed() berechnet einen Wert neu, sobald sich eine abhängige ref ändert.
//
// Anzahl der erledigten ToDos auf der aktuellen Seite
const completedCount = computed(
() => todos.value.filter(t => t.completed).length
)
//
// API-FUNKTIONEN
// async/await macht asynchronen Code lesbar wie synchronen.
// try/catch fängt Fehler ab (Netzwerkprobleme, HTTP-Fehler usw.).
//
/**
* loadTodos() ToDos vom Server laden
* Baut die aktuellen Filter/Sortier/Paginierungsparameter zusammen
* und ruft die API auf.
*/
async function loadTodos() {
loading.value = true
error.value = null
try {
const nextTodos = await fetchTodos({
page: page.value,
limit: limit.value,
search: search.value,
completed: filterCompleted.value,
sortBy: sortBy.value,
sortDir: sortDir.value,
})
todos.value = nextTodos
} catch (err) {
error.value = err.message
} finally {
// finally wird immer ausgeführt (egal ob Erfolg oder Fehler)
loading.value = false
}
}
/**
* compareTodoLists() zwei ToDo-Listen vergleichen
* Liefert die Anzahl neuer, geaenderter und geloeschter Elemente.
*/
function compareTodoLists(previous, next) {
const prevById = new Map(previous.map(todo => [todo.id, todo]))
const nextById = new Map(next.map(todo => [todo.id, todo]))
let added = 0
let changed = 0
let removed = 0
for (const [id, nextTodo] of nextById.entries()) {
const prevTodo = prevById.get(id)
if (!prevTodo) {
added++
continue
}
const titleChanged = prevTodo.title !== nextTodo.title
const completedChanged = prevTodo.completed !== nextTodo.completed
const userChanged = prevTodo.userId !== nextTodo.userId
if (titleChanged || completedChanged || userChanged) {
changed++
}
}
for (const id of prevById.keys()) {
if (!nextById.has(id)) {
removed++
}
}
return { added, changed, removed }
}
/**
* pollTodos() alle 5 Sekunden laden und mit aktuellem DOM-Stand vergleichen
*
* Vue rendert die DOM-Aenderungen automatisch, sobald todos.value ersetzt wird.
* Der Vergleich hier entscheidet, ob sich im Datenmodell etwas geaendert hat.
*/
async function pollTodos() {
if (requestInFlight) return
requestInFlight = true
try {
const nextTodos = await fetchTodos({
page: page.value,
limit: limit.value,
search: search.value,
completed: filterCompleted.value,
sortBy: sortBy.value,
sortDir: sortDir.value,
})
const { added, changed, removed } = compareTodoLists(todos.value, nextTodos)
if (added || changed || removed) {
todos.value = nextTodos
syncStatus.value = `Synchronisiert (${new Date().toLocaleTimeString('de-CH')}) · Neu: ${added}, Geaendert: ${changed}, Geloescht: ${removed}`
} else {
syncStatus.value = `Synchronisiert (${new Date().toLocaleTimeString('de-CH')}) · Keine Aenderung`
}
} catch (err) {
syncStatus.value = `Synchronisation fehlgeschlagen (${new Date().toLocaleTimeString('de-CH')})`
error.value = err.message
} finally {
requestInFlight = false
}
}
function startPolling() {
stopPolling()
pollTimerId = window.setInterval(pollTodos, syncIntervalMs)
}
function stopPolling() {
if (pollTimerId) {
window.clearInterval(pollTimerId)
pollTimerId = null
}
}
/**
* addTodo() Neues ToDo erstellen
* Sendet ein POST-Request und lädt danach die Liste neu.
*/
async function addTodo() {
formError.value = null
// Einfache Client-seitige Validierung vor dem API-Aufruf
if (!newTitle.value.trim()) {
formError.value = 'Bitte einen Titel eingeben.'
return
}
try {
await createTodo({
userId: newUserId.value,
title: newTitle.value.trim(),
completed: false,
})
// Eingabefelder zurücksetzen
newTitle.value = ''
// Liste neu laden, damit das neue ToDo erscheint
await loadTodos()
} catch (err) {
formError.value = err.message
}
}
/**
* toggleCompleted() Erledigungsstatus eines ToDos umschalten
* Verwendet PATCH, um nur das Feld "completed" zu aktualisieren.
* Optimistisches Update: Die lokale Liste wird sofort aktualisiert,
* ohne auf die API-Antwort zu warten (bessere UX).
*/
async function toggleCompleted(todo) {
// Optimistisches Update: Wert lokal sofort ändern
todo.completed = !todo.completed
try {
await patchTodo(todo.id, { completed: todo.completed })
} catch (err) {
// Bei Fehler: Änderung rückgängig machen
todo.completed = !todo.completed
error.value = err.message
}
}
/**
* removeTodo() ToDo löschen
* Sendet ein DELETE-Request; bei Erfolg wird das Todo aus der
* lokalen Liste entfernt (kein erneutes Laden nötig).
*/
async function removeTodo(id) {
try {
await deleteTodo(id)
// ToDo lokal aus dem Array entfernen
todos.value = todos.value.filter(t => t.id !== id)
} catch (err) {
error.value = err.message
}
}
/**
* applyFilter() Setzt die Seite auf 1 und lädt neu
* Wird aufgerufen, wenn Filter/Suche/Sortierung geändert wird.
*/
function applyFilter() {
page.value = 1
loadTodos()
}
//
// LIFECYCLE & WATCHER
//
// onMounted: Wird einmalig ausgeführt, nachdem die Komponente im DOM ist.
// Hier starten wir den ersten API-Aufruf.
onMounted(() => {
loadTodos()
startPolling()
})
// Aufraeumen beim Verlassen der Seite/Komponente
onUnmounted(() => {
stopPolling()
})
// watch: Überwacht "page" und lädt neue Daten, sobald sich die Seite ändert.
// Das ist nötig, weil die Pagination-Buttons nur page.value ändern.
watch(page, () => {
loadTodos()
})
</script>
<template>
<!--
Das Template definiert die HTML-Struktur der Komponente.
Vue-Direktiven im Einsatz:
v-if / v-else Bedingtes Rendern
v-for Liste rendern (immer :key angeben!)
v-model Zwei-Wege-Datenbindung (Input ref)
:class Dynamische CSS-Klassen (Kurzform von v-bind:class)
@click Event-Listener (Kurzform von v-on:click)
@submit.prevent submit-Event abfangen und preventDefault() aufrufen
-->
<div class="app">
<!-- Kopfzeile -->
<header class="app-header">
<h1>📝 ToDo-App</h1>
<p class="subtitle">Vue 3 + Vite · REST-API Beispiel</p>
</header>
<main class="app-main">
<!-- Filterleiste -->
<!--
v-model bindet das Input direkt an eine ref.
@keyup.enter und @change rufen applyFilter() auf,
damit bei Eingabe die Liste aktualisiert wird.
-->
<section class="filter-bar">
<input
v-model="search"
@keyup.enter="applyFilter"
type="text"
placeholder="Suche im Titel..."
class="input"
/>
<select v-model="filterCompleted" @change="applyFilter" class="select">
<option value="">Alle</option>
<option value="false">Offen</option>
<option value="true">Erledigt</option>
</select>
<select v-model="sortBy" @change="applyFilter" class="select">
<option value="id">Sortierung: ID</option>
<option value="title">Sortierung: Titel</option>
<option value="completed">Sortierung: Status</option>
</select>
<select v-model="sortDir" @change="applyFilter" class="select">
<option value="ASC"> Aufsteigend</option>
<option value="DESC"> Absteigend</option>
</select>
<button @click="applyFilter" class="btn btn-secondary">Suchen</button>
</section>
<!-- Statuszeile -->
<!--
computed-Werte werden wie normale Variablen im Template verwendet.
{{ }} ist die Interpolation (Textausgabe).
-->
<div class="status-bar" v-if="todos.length > 0">
{{ todos.length }} ToDos geladen · {{ completedCount }} erledigt
</div>
<div class="status-bar">{{ syncStatus }}</div>
<!-- Fehlermeldung -->
<!-- v-if rendert das Element nur, wenn error einen Wert hat (truthy) -->
<div v-if="error" class="alert alert-error">
{{ error }}
<button @click="error = null" class="btn-close"></button>
</div>
<!-- Ladeanzeige -->
<div v-if="loading" class="loading">Laden...</div>
<!-- ToDo-Liste -->
<!--
v-else-if und v-else funktionieren wie if/else, müssen aber
direkt nach einem v-if- bzw. v-else-if-Element stehen.
-->
<div v-else-if="todos.length === 0 && !loading" class="empty">
Keine ToDos gefunden.
</div>
<ul v-else class="todo-list">
<!--
v-for iteriert über das todos-Array.
:key ist zwingend erforderlich: Vue nutzt es, um DOM-Elemente
effizient zu aktualisieren (kein unnötiges Re-Rendern).
:class wendet eine CSS-Klasse bedingt an:
{ 'todo-done': todo.completed }
Klasse 'todo-done' wird gesetzt, wenn todo.completed true ist
-->
<li
v-for="todo in todos"
:key="todo.id"
:class="{ 'todo-done': todo.completed }"
class="todo-item"
>
<!-- Checkbox zum Umschalten des Erledigungsstatus -->
<input
type="checkbox"
:checked="todo.completed"
@change="toggleCompleted(todo)"
class="todo-checkbox"
/>
<span class="todo-content">
<span class="todo-title">{{ todo.title }}</span>
<span class="todo-meta">ID: {{ todo.id }} · User: {{ todo.userId }}</span>
</span>
<!-- Löschen-Button: @click ruft removeTodo mit der todo.id auf -->
<button @click="removeTodo(todo.id)" class="btn btn-danger" title="Löschen">
</button>
</li>
</ul>
<!-- Pagination -->
<!--
:disabled deaktiviert den Button (HTML-Attribut disabled setzen),
wenn die Bedingung true ist.
-->
<div class="pagination">
<button
@click="page--"
:disabled="page <= 1"
class="btn btn-secondary"
>
Zurück
</button>
<span class="page-info">Seite {{ page }}</span>
<button
@click="page++"
:disabled="todos.length < limit"
class="btn btn-secondary"
>
Weiter
</button>
<!-- Einträge pro Seite ändern -->
<select v-model.number="limit" @change="applyFilter" class="select">
<option :value="5">5 pro Seite</option>
<option :value="10">10 pro Seite</option>
<option :value="20">20 pro Seite</option>
</select>
</div>
<!-- Neues ToDo erstellen -->
<!--
@submit.prevent: Verhindert das Standard-Formularverhalten
(Seite neu laden) und ruft stattdessen addTodo() auf.
-->
<section class="add-form">
<h2>Neues ToDo hinzufügen</h2>
<form @submit.prevent="addTodo" class="form-row">
<input
v-model.number="newUserId"
type="number"
min="1"
max="10"
placeholder="User-ID"
class="input input-small"
/>
<input
v-model="newTitle"
type="text"
placeholder="Titel des neuen ToDos..."
class="input"
/>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</form>
<!-- Formular-Fehlermeldung -->
<p v-if="formError" class="alert alert-error">{{ formError }}</p>
</section>
</main>
</div>
</template>
<style>
/**
* Globale CSS-Styles (kein "scoped", damit auch body/html erfasst werden)
*
* Designprinzipien:
* - Kein externes CSS-Framework
* - CSS Custom Properties (Variablen) für einfache Anpassbarkeit
* - Schlichte, übersichtliche Gestaltung
*/
/* CSS-Variablen (Custom Properties)
Definiert im :root-Selektor, damit sie global verfügbar sind.
Vorteil: Farben und Abstände zentral ändern.
*/
:root {
--color-bg: #f5f5f5;
--color-surface: #ffffff;
--color-primary: #4a90d9;
--color-primary-hover: #357abd;
--color-danger: #e05050;
--color-danger-hover: #c03030;
--color-secondary: #6c757d;
--color-border: #ddd;
--color-text: #333;
--color-text-muted: #888;
--color-done: #aaa;
--color-error-bg: #fdecea;
--color-error-border: #f5c6cb;
--radius: 6px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* ── Reset & Basis ───────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
}
/* ── App-Layout ──────────────────────────────────────────────────────────── */
.app {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.app-header {
text-align: center;
padding: 2rem 1rem 1rem;
}
.app-header h1 {
font-size: 2rem;
font-weight: 700;
}
.subtitle {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.app-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Filterleiste ────────────────────────────────────────────────────────── */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
background: var(--color-surface);
padding: 1rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* ── Formular-Elemente ───────────────────────────────────────────────────── */
.input {
flex: 1;
min-width: 120px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.input:focus {
border-color: var(--color-primary);
}
.input-small {
flex: 0 0 90px;
}
.select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.9rem;
background: var(--color-surface);
cursor: pointer;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-secondary {
background: #e9ecef;
color: var(--color-text);
}
.btn-secondary:hover:not(:disabled) {
background: #d4d9de;
}
.btn-danger {
background: transparent;
color: var(--color-danger);
padding: 0.25rem 0.5rem;
font-size: 1rem;
}
.btn-danger:hover {
background: var(--color-error-bg);
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: var(--color-danger);
margin-left: 0.5rem;
}
/* ── Statuszeile ─────────────────────────────────────────────────────────── */
.status-bar {
font-size: 0.85rem;
color: var(--color-text-muted);
padding: 0 0.25rem;
}
/* ── Alerts ──────────────────────────────────────────────────────────────── */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: space-between;
}
.alert-error {
background: var(--color-error-bg);
border: 1px solid var(--color-error-border);
color: #721c24;
}
/* ── Ladeanzeige / Leerstate ─────────────────────────────────────────────── */
.loading,
.empty {
text-align: center;
padding: 2rem;
color: var(--color-text-muted);
font-style: italic;
}
/* ── ToDo-Liste ──────────────────────────────────────────────────────────── */
.todo-list {
list-style: none;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.todo-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
transition: background 0.15s;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item:hover {
background: #fafafa;
}
/* Erledigte ToDos werden ausgegraut und durchgestrichen */
.todo-done .todo-title {
text-decoration: line-through;
color: var(--color-done);
}
.todo-checkbox {
flex-shrink: 0;
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
accent-color: var(--color-primary);
}
.todo-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.todo-title {
font-size: 0.95rem;
/* Langer Text wird abgeschnitten statt umzubrechen */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.page-info {
font-size: 0.9rem;
color: var(--color-text-muted);
min-width: 60px;
text-align: center;
}
/* ── Neues ToDo ──────────────────────────────────────────────────────────── */
.add-form {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem;
}
.add-form h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>

134
src/api.js Normal file
View File

@ -0,0 +1,134 @@
// src/api.js
// API-Schicht: Alle Kommunikation mit dem Backend
//
// Diese Datei kapselt sämtliche HTTP-Aufrufe an die REST-API.
// Durch diese Trennung bleibt der Rest der App unabhängig vom Backend.
//
// API-Basis: https://webdev.iten-web.ch/10003/api
// Best-Practice-Routing mit REST-Pfaden:
// GET /10003/api/todos
// GET /10003/api/todos/1
//
// Datenmodell Todo:
// { id: number, userId: number, title: string, completed: boolean }
// Basis-URL der API an einem zentralen Ort definiert,
// damit bei Änderungen nur diese eine Zeile angepasst werden muss.
const API_BASE = 'https://webdev.iten-web.ch/10003/api'
// ---------------------------------------------------------------------------
// Hilfsfunktion: URL mit Pfad und optionalen Query-Parametern zusammensetzen
// ---------------------------------------------------------------------------
// Beispiel: buildUrl('/todos', { page: 1, limit: 10 })
// → 'https://webdev.iten-web.ch/10003/api/todos?page=1&limit=10'
//
// URLSearchParams wandelt ein Objekt automatisch in einen Query-String um
// und kümmert sich dabei um das korrekte Encoding der Werte.
function buildUrl(path, params = {}) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const url = new URL(`${API_BASE}${normalizedPath}`)
url.search = new URLSearchParams(params).toString()
return url.toString()
}
// ---------------------------------------------------------------------------
// Hilfsfunktion: HTTP-Anfrage ausführen und Fehler einheitlich behandeln
// ---------------------------------------------------------------------------
// fetch() gibt ein Promise zurück. Mit async/await warten wir auf die Antwort.
// response.ok ist true bei HTTP-Statuscodes 200299.
async function request(url, options = {}) {
const response = await fetch(url, options)
// Bei HTTP-Fehler (z.B. 404, 400): Fehlermeldung aus dem Response-Body lesen
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `HTTP-Fehler: ${response.status}`)
}
// Status 204 (No Content) hat keinen Body → direkt null zurückgeben
if (response.status === 204) {
return null
}
// Antwort als JSON parsen und zurückgeben
return response.json()
}
// ---------------------------------------------------------------------------
// GET /todos Liste von ToDos abrufen (mit Filter, Sortierung, Pagination)
// ---------------------------------------------------------------------------
// Parameter (alle optional):
// page Seitennummer (Standard: 1)
// limit Einträge pro Seite (Standard: 5, Max: 100)
// search Suche im Titel
// completed Filter: 'true' | 'false'
// sortBy Sortierfeld: 'id' | 'userId' | 'title' | 'completed'
// sortDir Richtung: 'ASC' | 'DESC'
export async function fetchTodos(params = {}) {
// Leere Strings aus den Parametern entfernen, damit sie nicht als
// leere Query-Parameter mitgesendet werden (z.B. &search=)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== '' && v !== null && v !== undefined)
)
const url = buildUrl('/todos', cleanParams)
return request(url)
}
// ---------------------------------------------------------------------------
// GET /todos/:id Ein einzelnes ToDo per ID abrufen
// ---------------------------------------------------------------------------
export async function fetchTodo(id) {
const url = buildUrl(`/todos/${id}`)
return request(url)
}
// ---------------------------------------------------------------------------
// POST /todos Neues ToDo erstellen
// ---------------------------------------------------------------------------
// body: { userId: number, title: string, completed?: boolean }
// Gibt das neu erstellte ToDo mit server-seitig vergebener ID zurück.
export async function createTodo(body) {
const url = buildUrl('/todos')
return request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
// ---------------------------------------------------------------------------
// PATCH /todos/:id ToDo teilweise aktualisieren
// ---------------------------------------------------------------------------
// Im Unterschied zu PUT werden nur die übergebenen Felder aktualisiert.
// Hier verwenden wir es, um den completed-Status umzuschalten.
// body: Teilobjekt, z.B. { completed: true }
export async function patchTodo(id, body) {
const url = buildUrl(`/todos/${id}`)
return request(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
// ---------------------------------------------------------------------------
// PUT /todos/:id ToDo vollständig ersetzen
// ---------------------------------------------------------------------------
// Alle Felder (userId, title, completed) müssen mitgesendet werden.
export async function updateTodo(id, body) {
const url = buildUrl(`/todos/${id}`)
return request(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
// ---------------------------------------------------------------------------
// DELETE /todos/:id ToDo löschen
// ---------------------------------------------------------------------------
// Die API antwortet mit Status 204 (No Content) → kein Rückgabewert
export async function deleteTodo(id) {
const url = buildUrl(`/todos/${id}`)
return request(url, { method: 'DELETE' })
}

11
src/main.js Normal file
View File

@ -0,0 +1,11 @@
// src/main.js
// Einstiegspunkt der Vue-Anwendung
//
// Hier wird die Vue-App erstellt und in den DOM eingehängt.
// createApp() erzeugt eine neue Vue-Anwendungsinstanz.
// mount('#app') verbindet sie mit dem <div id="app"> in index.html.
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

15
vite.config.js Normal file
View File

@ -0,0 +1,15 @@
// vite.config.js
// Konfiguration des Vite Build-Tools
//
// Vite ist ein modernes Build-Tool, das:
// - einen schnellen Entwicklungsserver bereitstellt (Hot Module Replacement)
// - Vue Single-File-Components (.vue) verarbeitet
// - die App für die Produktion bündelt und optimiert
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// Das Vue-Plugin aktiviert die Verarbeitung von .vue-Dateien
plugins: [vue()],
})