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