build app
This commit is contained in:
parent
42fcb8f645
commit
4fe9cb9be4
97
README.md
97
README.md
@ -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
22
index.html
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 – 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
1246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
763
src/App.vue
Normal 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 (1–10)
|
||||||
|
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
134
src/api.js
Normal 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 200–299.
|
||||||
|
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
11
src/main.js
Normal 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
15
vite.config.js
Normal 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()],
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user