Projekt: Dashboard-App
Baue eine komplette Dashboard-Anwendung mit React. Datenvisualisierung, Tabellen, Authentifizierung und geschützte Routen.
Projekt: Dashboard-App
Dies ist das Abschlussprojekt des React-Kurses. Du baust eine vollstaendige Dashboard-Anwendung, die alles zusammenfuehrt: Routing, State Management, Authentifizierung, Datenlade-Patterns und geschuetzte Routen.
Was wir bauen
- Login-System mit geschuetzten Routen
- Dashboard-Uebersicht mit Statistik-Karten
- Datentabelle mit Sortierung, Filter und Suche
- Benutzer-Verwaltung (CRUD-Operationen)
- Dark Mode mit Context
- Responsive Sidebar-Navigation
Projekt aufsetzen
npm create vite@latest react-dashboard -- --template react
cd react-dashboard
npm install react-router-dom
npm run dev
Projektstruktur
src/
├── components/
│ ├── Sidebar.jsx
│ ├── StatsCard.jsx
│ ├── DataTable.jsx
│ ├── UserForm.jsx
│ └── ProtectedRoute.jsx
├── context/
│ ├── AuthContext.jsx
│ └── ThemeContext.jsx
├── data/
│ └── mockData.js
├── hooks/
│ └── useFetch.js
├── pages/
│ ├── Login.jsx
│ ├── Dashboard.jsx
│ ├── Users.jsx
│ ├── UserDetail.jsx
│ └── Settings.jsx
├── App.jsx
└── main.jsx
Schritt 1: Mock-Daten
// src/data/mockData.js
export const mockUsers = [
{ id: 1, name: 'Max Mustermann', email: 'max@example.com', role: 'Admin', status: 'active', joined: '2024-01-15' },
{ id: 2, name: 'Anna Schmidt', email: 'anna@example.com', role: 'Editor', status: 'active', joined: '2024-03-22' },
{ id: 3, name: 'Tom Weber', email: 'tom@example.com', role: 'User', status: 'inactive', joined: '2024-06-10' },
{ id: 4, name: 'Lisa Mueller', email: 'lisa@example.com', role: 'Editor', status: 'active', joined: '2024-07-05' },
{ id: 5, name: 'Jan Koch', email: 'jan@example.com', role: 'User', status: 'active', joined: '2024-09-18' },
{ id: 6, name: 'Sarah Braun', email: 'sarah@example.com', role: 'Admin', status: 'active', joined: '2024-11-01' },
{ id: 7, name: 'Paul Richter', email: 'paul@example.com', role: 'User', status: 'inactive', joined: '2025-01-12' },
{ id: 8, name: 'Maria Wagner', email: 'maria@example.com', role: 'Editor', status: 'active', joined: '2025-02-28' }
];
export const mockStats = {
totalUsers: 1234,
activeUsers: 892,
revenue: 45678,
orders: 356,
growth: 12.5,
conversionRate: 3.2
};
Schritt 2: Theme-Context
// src/context/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(() => {
return localStorage.getItem('theme') === 'dark';
});
useEffect(() => {
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.body.style.backgroundColor = isDark ? '#0f172a' : '#f8fafc';
document.body.style.color = isDark ? '#e2e8f0' : '#1e293b';
}, [isDark]);
function toggleTheme() {
setIsDark(prev => !prev);
}
const theme = {
isDark,
toggleTheme,
colors: isDark ? {
bg: '#0f172a',
bgCard: '#1e293b',
bgSidebar: '#1e293b',
text: '#e2e8f0',
textSecondary: '#94a3b8',
border: '#334155',
primary: '#3b82f6',
hover: '#334155'
} : {
bg: '#f8fafc',
bgCard: '#ffffff',
bgSidebar: '#ffffff',
text: '#1e293b',
textSecondary: '#64748b',
border: '#e2e8f0',
primary: '#2563eb',
hover: '#f1f5f9'
}
};
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
Schritt 3: Auth-Context
// src/context/AuthContext.jsx
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
function login(email, password) {
if (email === 'admin@demo.com' && password === 'demo123') {
const userData = { id: 1, name: 'Admin User', email, role: 'admin' };
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
return { success: true };
}
return { success: false, error: 'Ungueltige Zugangsdaten' };
}
function logout() {
setUser(null);
localStorage.removeItem('user');
}
return (
<AuthContext.Provider value={{ user, isLoggedIn: !!user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
Schritt 4: ProtectedRoute
// src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
function ProtectedRoute({ children }) {
const { isLoggedIn } = useAuth();
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;
Schritt 5: Sidebar-Navigation
// src/components/Sidebar.jsx
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
function Sidebar() {
const { user, logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: '📊' },
{ to: '/users', label: 'Benutzer', icon: '👥' },
{ to: '/settings', label: 'Einstellungen', icon: '⚙️' }
];
return (
<aside style={{
width: '250px',
backgroundColor: colors.bgSidebar,
borderRight: `1px solid ${colors.border}`,
display: 'flex',
flexDirection: 'column',
height: '100vh',
position: 'sticky',
top: 0
}}>
<div style={{ padding: '1.5rem', borderBottom: `1px solid ${colors.border}` }}>
<h2 style={{ fontSize: '1.25rem', color: colors.primary }}>
Dashboard
</h2>
</div>
<nav style={{ flex: 1, padding: '1rem 0' }}>
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.5rem',
textDecoration: 'none',
color: isActive ? colors.primary : colors.text,
backgroundColor: isActive ? (isDark ? '#1e3a5f' : '#eff6ff') : 'transparent',
borderRight: isActive ? `3px solid ${colors.primary}` : '3px solid transparent',
fontWeight: isActive ? 600 : 400,
transition: 'all 0.15s'
})}
>
<span>{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div style={{ padding: '1rem 1.5rem', borderTop: `1px solid ${colors.border}` }}>
<button
onClick={toggleTheme}
style={{
width: '100%',
padding: '8px',
marginBottom: '0.5rem',
backgroundColor: 'transparent',
border: `1px solid ${colors.border}`,
borderRadius: '6px',
color: colors.text,
cursor: 'pointer'
}}
>
{isDark ? 'Light Mode' : 'Dark Mode'}
</button>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: '0.5rem'
}}>
<div>
<p style={{ fontWeight: 600, fontSize: '0.9rem' }}>{user?.name}</p>
<p style={{ color: colors.textSecondary, fontSize: '0.8rem' }}>{user?.role}</p>
</div>
<button onClick={logout} style={{
padding: '4px 10px',
border: `1px solid ${colors.border}`,
borderRadius: '4px',
backgroundColor: 'transparent',
color: colors.textSecondary,
cursor: 'pointer',
fontSize: '0.8rem'
}}>
Logout
</button>
</div>
</div>
</aside>
);
}
export default Sidebar;
Schritt 6: StatsCard-Komponente
// src/components/StatsCard.jsx
import { useTheme } from '../context/ThemeContext';
function StatsCard({ title, value, change, icon }) {
const { colors } = useTheme();
const isPositive = change >= 0;
return (
<div style={{
backgroundColor: colors.bgCard,
border: `1px solid ${colors.border}`,
borderRadius: '12px',
padding: '1.5rem'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<div>
<p style={{
color: colors.textSecondary,
fontSize: '0.85rem',
marginBottom: '0.5rem'
}}>
{title}
</p>
<p style={{ fontSize: '1.75rem', fontWeight: 700 }}>{value}</p>
</div>
<span style={{ fontSize: '1.5rem' }}>{icon}</span>
</div>
{change !== undefined && (
<p style={{
marginTop: '0.75rem',
fontSize: '0.85rem',
color: isPositive ? '#16a34a' : '#dc2626'
}}>
{isPositive ? '+' : ''}{change}% gegenueber Vormonat
</p>
)}
</div>
);
}
export default StatsCard;
Schritt 7: DataTable-Komponente
// src/components/DataTable.jsx
import { useState, useMemo } from 'react';
import { useTheme } from '../context/ThemeContext';
function DataTable({ data, columns, onRowClick }) {
const { colors } = useTheme();
const [sortField, setSortField] = useState(null);
const [sortOrder, setSortOrder] = useState('asc');
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const rowsPerPage = 5;
function handleSort(field) {
if (sortField === field) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
}
const filteredData = useMemo(() => {
let result = data.filter(row =>
columns.some(col =>
String(row[col.key]).toLowerCase().includes(search.toLowerCase())
)
);
if (sortField) {
result = [...result].sort((a, b) => {
const valA = a[sortField];
const valB = b[sortField];
const comparison = typeof valA === 'string'
? valA.localeCompare(valB)
: valA - valB;
return sortOrder === 'asc' ? comparison : -comparison;
});
}
return result;
}, [data, search, sortField, sortOrder, columns]);
const totalPages = Math.ceil(filteredData.length / rowsPerPage);
const paginatedData = filteredData.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage
);
return (
<div>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
placeholder="Suchen..."
style={{
padding: '8px 12px',
border: `1px solid ${colors.border}`,
borderRadius: '6px',
marginBottom: '1rem',
width: '300px',
backgroundColor: colors.bgCard,
color: colors.text
}}
/>
<div style={{
border: `1px solid ${colors.border}`,
borderRadius: '8px',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: colors.hover }}>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
style={{
padding: '12px 16px',
textAlign: 'left',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
color: colors.textSecondary,
userSelect: 'none',
borderBottom: `1px solid ${colors.border}`
}}
>
{col.label}
{sortField === col.key && (sortOrder === 'asc' ? ' ▲' : ' ▼')}
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map(row => (
<tr
key={row.id}
onClick={() => onRowClick?.(row)}
style={{
cursor: onRowClick ? 'pointer' : 'default',
borderBottom: `1px solid ${colors.border}`,
transition: 'background-color 0.1s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.hover}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
{columns.map(col => (
<td key={col.key} style={{
padding: '12px 16px',
fontSize: '0.9rem'
}}>
{col.render ? col.render(row[col.key], row) : row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '1rem',
fontSize: '0.85rem',
color: colors.textSecondary
}}>
<span>
Zeige {((currentPage - 1) * rowsPerPage) + 1}-
{Math.min(currentPage * rowsPerPage, filteredData.length)} von {filteredData.length}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
style={{
padding: '6px 12px',
border: `1px solid ${colors.border}`,
borderRadius: '4px',
backgroundColor: colors.bgCard,
color: colors.text,
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
opacity: currentPage === 1 ? 0.5 : 1
}}
>
Zurueck
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
style={{
padding: '6px 12px',
border: `1px solid ${colors.border}`,
borderRadius: '4px',
backgroundColor: colors.bgCard,
color: colors.text,
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
opacity: currentPage === totalPages ? 0.5 : 1
}}
>
Weiter
</button>
</div>
</div>
</div>
);
}
export default DataTable;
Schritt 8: Login-Seite
// src/pages/Login.jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
function Login() {
const [email, setEmail] = useState('admin@demo.com');
const [password, setPassword] = useState('demo123');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
function handleSubmit(e) {
e.preventDefault();
const result = login(email, password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error);
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f1f5f9'
}}>
<div style={{
backgroundColor: 'white',
padding: '2.5rem',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0,0,0,0.07)',
width: '100%',
maxWidth: '400px'
}}>
<h1 style={{ textAlign: 'center', marginBottom: '0.5rem' }}>Dashboard Login</h1>
<p style={{ textAlign: 'center', color: '#64748b', marginBottom: '1.5rem' }}>
Demo: admin@demo.com / demo123
</p>
{error && (
<div style={{
padding: '0.75rem',
backgroundColor: '#fef2f2',
color: '#dc2626',
borderRadius: '6px',
marginBottom: '1rem',
fontSize: '0.9rem'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 500 }}>E-Mail</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', padding: '10px', border: '1px solid #e2e8f0',
borderRadius: '6px', fontSize: '1rem' }} />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 500 }}>Passwort</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
style={{ width: '100%', padding: '10px', border: '1px solid #e2e8f0',
borderRadius: '6px', fontSize: '1rem' }} />
</div>
<button type="submit" style={{
width: '100%', padding: '12px', backgroundColor: '#2563eb',
color: 'white', border: 'none', borderRadius: '6px',
fontSize: '1rem', fontWeight: 600, cursor: 'pointer'
}}>
Anmelden
</button>
</form>
</div>
</div>
);
}
export default Login;
Schritt 9: Dashboard-Seite
// src/pages/Dashboard.jsx
import { mockStats } from '../data/mockData';
import StatsCard from '../components/StatsCard';
import { useTheme } from '../context/ThemeContext';
function Dashboard() {
const { colors } = useTheme();
return (
<div>
<h1 style={{ marginBottom: '0.5rem' }}>Dashboard</h1>
<p style={{ color: colors.textSecondary, marginBottom: '2rem' }}>
Willkommen zurueck! Hier ist deine Uebersicht.
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '1.5rem',
marginBottom: '2rem'
}}>
<StatsCard
title="Benutzer gesamt"
value={mockStats.totalUsers.toLocaleString('de-DE')}
change={mockStats.growth}
icon="👥"
/>
<StatsCard
title="Aktive Benutzer"
value={mockStats.activeUsers.toLocaleString('de-DE')}
change={8.3}
icon="✓"
/>
<StatsCard
title="Umsatz"
value={`${mockStats.revenue.toLocaleString('de-DE')} €`}
change={mockStats.growth}
icon="💰"
/>
<StatsCard
title="Bestellungen"
value={mockStats.orders.toLocaleString('de-DE')}
change={-2.4}
icon="📦"
/>
</div>
{/* Aktivitaets-Uebersicht */}
<div style={{
backgroundColor: colors.bgCard,
border: `1px solid ${colors.border}`,
borderRadius: '12px',
padding: '1.5rem'
}}>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem' }}>
Letzte Aktivitaeten
</h2>
{[
{ action: 'Neuer Benutzer registriert', user: 'Maria Wagner', time: 'vor 5 Min.' },
{ action: 'Bestellung aufgegeben', user: 'Jan Koch', time: 'vor 12 Min.' },
{ action: 'Profil aktualisiert', user: 'Anna Schmidt', time: 'vor 25 Min.' },
{ action: 'Support-Ticket erstellt', user: 'Tom Weber', time: 'vor 1 Std.' },
{ action: 'Neue Bewertung geschrieben', user: 'Paul Richter', time: 'vor 2 Std.' }
].map((activity, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 0',
borderBottom: index < 4 ? `1px solid ${colors.border}` : 'none'
}}>
<div>
<p style={{ fontWeight: 500 }}>{activity.action}</p>
<p style={{ fontSize: '0.85rem', color: colors.textSecondary }}>{activity.user}</p>
</div>
<span style={{ fontSize: '0.8rem', color: colors.textSecondary }}>{activity.time}</span>
</div>
))}
</div>
</div>
);
}
export default Dashboard;
Schritt 10: Benutzer-Seite mit DataTable
// src/pages/Users.jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { mockUsers } from '../data/mockData';
import DataTable from '../components/DataTable';
import { useTheme } from '../context/ThemeContext';
function Users() {
const { colors } = useTheme();
const navigate = useNavigate();
const [users] = useState(mockUsers);
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'E-Mail' },
{ key: 'role', label: 'Rolle',
render: (value) => (
<span style={{
padding: '2px 10px',
borderRadius: '12px',
fontSize: '0.8rem',
fontWeight: 500,
backgroundColor: value === 'Admin' ? '#dbeafe' :
value === 'Editor' ? '#dcfce7' : '#f3f4f6',
color: value === 'Admin' ? '#1d4ed8' :
value === 'Editor' ? '#16a34a' : '#4b5563'
}}>
{value}
</span>
)
},
{ key: 'status', label: 'Status',
render: (value) => (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
fontSize: '0.85rem'
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: value === 'active' ? '#16a34a' : '#94a3b8'
}} />
{value === 'active' ? 'Aktiv' : 'Inaktiv'}
</span>
)
},
{ key: 'joined', label: 'Beigetreten' }
];
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem'
}}>
<div>
<h1>Benutzer</h1>
<p style={{ color: colors.textSecondary }}>{users.length} Benutzer gesamt</p>
</div>
<button style={{
padding: '10px 20px',
backgroundColor: colors.primary,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 600
}}>
Neuer Benutzer
</button>
</div>
<DataTable
data={users}
columns={columns}
onRowClick={(user) => navigate(`/users/${user.id}`)}
/>
</div>
);
}
export default Users;
Schritt 11: App zusammensetzen
// src/App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider, useTheme } from './context/ThemeContext';
import ProtectedRoute from './components/ProtectedRoute';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Users from './pages/Users';
function AppLayout() {
const { colors } = useTheme();
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar />
<main style={{
flex: 1,
padding: '2rem',
backgroundColor: colors.bg,
overflow: 'auto'
}}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<div><h1>Einstellungen</h1><p>Kommt bald...</p></div>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
);
}
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/*" element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
);
}
export default App;
Uebungen zur Erweiterung
- Erstelle eine Benutzer-Detailseite mit
useParams, die alle Infos eines Users zeigt - Implementiere CRUD-Operationen – Benutzer erstellen, bearbeiten und loeschen
- Fuege ein Benachrichtigungs-System hinzu mit einem Notification-Context
- Baue eine Einstellungs-Seite mit Profil-Bearbeitung und Theme-Auswahl
- Ersetze Mock-Daten durch echte API-Aufrufe mit dem useFetch-Hook
- Fuege Charts hinzu mit einer Bibliothek wie recharts
Was kommt als Naechstes?
Herzlichen Glueckwunsch – du hast den React-Kurs abgeschlossen! Du hast jetzt das Wissen, um professionelle React-Anwendungen zu bauen. Moegliche naechste Schritte:
- TypeScript lernen fuer typsicheres React
- Next.js fuer Server-Side Rendering
- TanStack Query fuer professionelles Daten-Management
- Testing mit Vitest und React Testing Library
Zusammenfassung
- Ein Dashboard vereint alle gelernten Konzepte: Routing, State, Context, Hooks
- Authentifizierung mit Context und geschuetzten Routen schuetzt sensible Bereiche
- DataTable mit Sortierung, Filter und Paginierung ist ein reales Anwendungsmuster
- Dark Mode zeigt, wie Theme-Systeme mit Context funktionieren
- Sidebar-Navigation mit React Router erstellt professionelle Layouts
- Komponentenarchitektur haelt auch komplexe Apps wartbar und uebersichtlich
Pro-Tipp: Dieses Dashboard ist ein hervorragendes Portfolio-Stueck! Erweitere es um echte API-Anbindung, fuege Tests hinzu und deploye es auf Vercel oder Netlify. Recruitern zeigt ein funktionierendes Dashboard-Projekt, dass du reale Anwendungen bauen kannst – das ist mehr wert als zehn Todo-Apps!