Skip to content

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 dev

Projekt-Struktur:

mein-todo-projekt/
├── src/
│   ├── components/
│   │   ├── TodoList.vue
│   │   ├── TodoItem.vue
│   │   ├── TodoForm.vue
│   │   └── TodoFilter.vue
│   ├── stores/
│   │   └── todo.js
│   ├── App.vue
│   └── main.js
├── index.html
└── package.json

40.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:

  1. Server starten: pnpm run dev
  2. Browser öffnen: http://localhost:5173
  3. 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 →

Frei für alle Anfänger