Appearance
Kapitel 40: Projekt - TodoList App
📙 Lernziel: Eine vollständige TodoList-App mit Vue 3 erstellen!
40.1 Projekt-Übersicht & Setup
Ziele:
- ✅ Todo hinzufügen
- ✅ Todo löschen
- ✅ Todo als erledigt markieren
- ✅ Todos filtern (alle/erledigt/offen)
- ✅ LocalStorage-Persistenz
Projekt-Setup:
bash
# 1. Projekt erstellen
pnpm create vite@latest
# Framework: Vue
# Variant: JavaScript
# 2. In Projektordner wechseln
cd mein-todo-projekt
# 3. Abhängigkeiten installieren
pnpm install
# 4. Pinia installieren (State Management)
pnpm add pinia
# 5. Dev-Server starten
pnpm run devProjekt-Struktur:
mein-todo-projekt/
├── src/
│ ├── components/
│ │ ├── TodoList.vue
│ │ ├── TodoItem.vue
│ │ ├── TodoForm.vue
│ │ └── TodoFilter.vue
│ ├── stores/
│ │ └── todo.js
│ ├── App.vue
│ └── main.js
├── index.html
└── package.json40.2 Pinia Store erstellen (stores/todo.js)
stores/todo.js:
javascript
// stores/todo.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export const useTodoStore = defineStore('todo', () => {
// State
const todos = ref(JSON.parse(localStorage.getItem('todos')) || [
{ id: 1, text: 'Vue 3 lernen', done: false },
{ id: 2, text: 'Projekt erstellen', done: true }
])
const filter = ref('all') // 'all', 'done', 'undone'
// Getters (computed)
const doneTodos = computed(() =>
todos.value.filter(todo => todo.done)
)
const undoneTodos = computed(() =>
todos.value.filter(todo => !todo.done)
)
const filteredTodos = computed(() => {
if (filter.value === 'done') {
return doneTodos.value
} else if (filter.value === 'undone') {
return undoneTodos.value
}
return todos.value
})
// Actions
const addTodo = (text) => {
if (text.trim() === '') return
todos.value.push({
id: Date.now(),
text: text.trim(),
done: false
})
}
const removeTodo = (id) => {
todos.value = todos.value.filter(todo => todo.id !== id)
}
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.done = !todo.done
}
}
const setFilter = (newFilter) => {
filter.value = newFilter
}
const clearDone = () => {
todos.value = undoneTodos.value
}
// LocalStorage Persistenz
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })
return {
todos,
filter,
doneTodos,
undoneTodos,
filteredTodos,
addTodo,
removeTodo,
toggleTodo,
setFilter,
clearDone
}
})40.3 Hauptkomponente (App.vue)
App.vue:
vue
<!-- src/App.vue -->
<script setup>
import TodoForm from './components/TodoForm.vue'
import TodoFilter from './components/TodoFilter.vue'
import TodoList from './components/TodoList.vue'
import { useTodoStore } from './stores/todo'
import { ref, onMounted } from 'vue'
const store = useTodoStore()
const newTodo = ref('')
const addTodo = () => {
if (newTodo.value.trim()) {
store.addTodo(newTodo.value)
newTodo.value = ''
}
}
// Focus input on mount
onMounted(() => {
document.querySelector('input[type="text"]')?.focus()
})
</script>
<template>
<div id="app">
<div class="todo-app">
<h1>📝 Meine Todo-Liste</h1>
<!-- Filter -->
<TodoFilter
:filter="store.filter"
@set-filter="store.setFilter"
/>
<!-- Form -->
<TodoForm
v-model="newTodo"
@add-todo="addTodo"
/>
<!-- Statistics -->
<div class="stats">
<span>Gesamt: {{ store.todos.length }}</span>
<span>Erledigt: {{ store.doneTodos.length }}</span>
<span>Offen: {{ store.undoneTodos.length }}</span>
<button
v-if="store.doneTodos.length > 0"
@click="store.clearDone()"
class="clear-btn"
>
Erledigte löschen
</button>
</div>
<!-- Todo List -->
<TodoList
:todos="store.filteredTodos"
@toggle-todo="store.toggleTodo"
@remove-todo="store.removeTodo"
/>
<!-- Empty State -->
<div v-if="store.filteredTodos.length === 0" class="empty-state">
<p>🎉 Keine Todos vorhanden!</p>
</div>
</div>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
#app {
width: 100%;
max-width: 600px;
}
.todo-app {
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
padding: 30px;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
font-size: 2.5rem;
}
.stats {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
font-size: 0.9rem;
color: #5a6c7d;
}
.clear-btn {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.3s;
}
.clear-btn:hover {
background: #c0392b;
}
.empty-state {
text-align: center;
padding: 40px;
color: #95a5a6;
font-size: 1.2rem;
}
@media (max-width: 640px) {
.stats {
flex-direction: column;
gap: 10px;
}
.todo-app {
padding: 20px;
}
}
</style>40.4 Form-Komponente (TodoForm.vue)
components/TodoForm.vue:
vue
<!-- src/components/TodoForm.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue', 'add-todo'])
const handleAdd = () => {
emit('add-todo')
}
</script>
<template>
<form @submit.prevent="handleAdd" class="todo-form">
<input
type="text"
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
placeholder="Neue Aufgabe hinzufügen..."
class="todo-input"
/>
<button type="submit" class="add-btn">
Hinzufügen
</button>
</form>
</template>
<style scoped>
.todo-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.todo-input:focus {
outline: none;
border-color: #667eea;
}
.add-btn {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.add-btn:hover {
background: #5a6cd8;
}
@media (max-width: 640px) {
.todo-form {
flex-direction: column;
}
}
</style>40.5 Filter-Komponente (TodoFilter.vue)
components/TodoFilter.vue:
vue
<!-- src/components/TodoFilter.vue -->
<script setup>
defineProps(['filter'])
defineEmits(['set-filter'])
</script>
<template>
<div class="todo-filter">
<button
:class="{ active: filter === 'all' }"
@click="emit('set-filter', 'all')"
>
Alle ({{ getCount('all') }})
</button>
<button
:class="{ active: filter === 'undone' }"
@click="emit('set-filter', 'undone')"
>
Offen ({{ getCount('undone') }})
</button>
<button
:class="{ active: filter === 'done' }"
@click="emit('set-filter', 'done')"
>
Erledigt ({{ getCount('done') }})
</button>
</div>
</template>
<script>
export default {
methods: {
getCount(type) {
const store = useTodoStore()
if (type === 'all') return store.todos.length
if (type === 'done') return store.doneTodos.length
if (type === 'undone') return store.undoneTodos.length
return 0
}
}
}
</script>
<style scoped>
.todo-filter {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.todo-filter button {
background: #f8f9fa;
border: 2px solid #e0e0e0;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
color: #5a6c7d;
}
.todo-filter button.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.todo-filter button:hover {
border-color: #667eea;
}
@media (max-width: 640px) {
.todo-filter {
flex-wrap: wrap;
}
}
</style>40.6 List-Komponente (TodoList.vue)
components/TodoList.vue:
vue
<!-- src/components/TodoList.vue -->
<script setup>
defineProps(['todos'])
defineEmits(['toggle-todo', 'remove-todo'])
</script>
<template>
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
class="todo-item"
:class="{ done: todo.done }"
>
<input
type="checkbox"
:checked="todo.done"
@change="emit('toggle-todo', todo.id)"
class="todo-checkbox"
/>
<span class="todo-text">{{ todo.text }}</span>
<button
@click="emit('remove-todo', todo.id)"
class="delete-btn"
>
✕
</button>
</div>
</div>
</template>
<style scoped>
.todo-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.3s;
border: 2px solid transparent;
}
.todo-item:hover {
background: #edf2f7;
border-color: #667eea;
}
.todo-item.done {
opacity: 0.6;
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #667eea;
}
.todo-text {
flex: 1;
font-size: 1rem;
color: #2c3e50;
}
.todo-item.done .todo-text {
text-decoration: line-through;
color: #95a5a6;
}
.delete-btn {
background: #e74c3c;
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
}
.delete-btn:hover {
background: #c0392b;
}
@media (max-width: 640px) {
.todo-item {
padding: 12px;
}
}
</style>40.7 Pinia in main.js registrieren
src/main.js:
javascript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')40.8 Projekt testen
Schritte:
- Server starten:
pnpm run dev - Browser öffnen:
http://localhost:5173 - Testen:
- ✅ Todo hinzufügen (Input + Button)
- ✅ Todo als erledigt markieren (Checkbox)
- ✅ Todo löschen (✕ Button)
- ✅ Filter testen (Alle/Offen/Erledigt)
- ✅ Erledigte löschen (Button)
- ✅ LocalStorage testen (Seite neu laden - Daten bleiben erhalten)
Erwartetes Ergebnis:
- ✅ Funktionale Todo-App
- ✅ Daten bleiben nach Reload erhalten
- ✅ Responsive Design (Mobile-optimiert)
- ✅ Schönes UI mit Gradient-Background
✅ Zusammenfassung
In diesem Kapitel hast du gelernt:
- ✅ Vue 3 Projekt mit Vite erstellen
- ✅ Pinia für State Management verwenden
- ✅ Komponenten-Struktur (Form, Filter, List)
- ✅ LocalStorage-Persistenz implementieren
- ✅ Reactive Filtersystem
- ✅ Responsive Design mit CSS
🎯 Nächster Schritt: In Kapitel 41 lernst du das User Management Projekt (mit Vue Router & Axios)!
← Zurück zu Kapitel 39: Fortgeschrittene KonzepteWeiter zu Kapitel 41: User Management Projekt →
