Intermediate Tutorial
Last updated
Was this helpful?
Was this helpful?
npx makes aurelia
# Name: todo-app
# Select TypeScript
cd todo-app
npm run devexport interface Todo {
id: string;
title: string;
description: string;
category: Category;
completed: boolean;
createdAt: Date;
}
export type Category = 'work' | 'personal' | 'shopping';
export const CATEGORIES: Category[] = ['work', 'personal', 'shopping'];
export const CATEGORY_LABELS: Record<Category, string> = {
work: 'Work',
personal: 'Personal',
shopping: 'Shopping'
};
export const CATEGORY_COLORS: Record<Category, string> = {
work: '#3b82f6',
personal: '#10b981',
shopping: '#f59e0b'
};import { DI } from 'aurelia';
export const IStorageService = DI.createInterface<IStorageService>(
'IStorageService',
x => x.singleton(StorageService)
);
export interface IStorageService extends StorageService {}
export class StorageService {
private readonly STORAGE_KEY = 'aurelia-todos';
saveTodos(todos: any[]): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save todos:', error);
}
}
loadTodos(): any[] {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Failed to load todos:', error);
return [];
}
}
clearTodos(): void {
localStorage.removeItem(this.STORAGE_KEY);
}
}import { resolve } from 'aurelia';
import { IStorageService } from './storage-service';
import { Todo, Category, CATEGORIES } from './models';
export class MyApp {
private readonly storage = resolve(IStorageService);
todos: Todo[] = [];
filterCategory: Category | 'all' = 'all';
filterCompleted: 'all' | 'active' | 'completed' = 'all';
constructor() {
this.loadTodos();
}
// Computed property for filtered todos
get filteredTodos(): Todo[] {
let filtered = this.todos;
// Filter by category
if (this.filterCategory !== 'all') {
filtered = filtered.filter(todo => todo.category === this.filterCategory);
}
// Filter by completion status
if (this.filterCompleted === 'active') {
filtered = filtered.filter(todo => !todo.completed);
} else if (this.filterCompleted === 'completed') {
filtered = filtered.filter(todo => todo.completed);
}
return filtered;
}
// Statistics computed properties
get totalTodos(): number {
return this.todos.length;
}
get activeTodos(): number {
return this.todos.filter(todo => !todo.completed).length;
}
get completedTodos(): number {
return this.todos.filter(todo => todo.completed).length;
}
get categories(): (Category | 'all')[] {
return ['all', ...CATEGORIES];
}
// Todo operations
addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): void {
const newTodo: Todo = {
...todo,
id: crypto.randomUUID(),
createdAt: new Date()
};
this.todos.push(newTodo);
this.saveTodos();
}
toggleTodo(todo: Todo): void {
todo.completed = !todo.completed;
this.saveTodos();
}
deleteTodo(todo: Todo): void {
const index = this.todos.indexOf(todo);
if (index > -1) {
this.todos.splice(index, 1);
this.saveTodos();
}
}
clearCompleted(): void {
this.todos = this.todos.filter(todo => !todo.completed);
this.saveTodos();
}
// Persistence
private saveTodos(): void {
this.storage.saveTodos(this.todos);
}
private loadTodos(): void {
const loaded = this.storage.loadTodos();
this.todos = loaded.map(todo => ({
...todo,
createdAt: new Date(todo.createdAt)
}));
}
}import { bindable } from 'aurelia';
import { Category, CATEGORIES, CATEGORY_LABELS } from './models';
export class TodoForm {
@bindable onSubmit?: (data: any) => void;
formData = {
title: '',
description: '',
category: 'work' as Category
};
categories = CATEGORIES;
categoryLabels = CATEGORY_LABELS;
get isValid(): boolean {
return this.formData.title.trim().length > 0;
}
handleSubmit(): void {
if (!this.isValid) return;
this.onSubmit?.({
title: this.formData.title.trim(),
description: this.formData.description.trim(),
category: this.formData.category,
completed: false
});
this.resetForm();
}
resetForm(): void {
this.formData = {
title: '',
description: '',
category: 'work'
};
}
}<div class="todo-form">
<h2>Add New Todo</h2>
<form submit.trigger="handleSubmit()">
<div class="form-group">
<label for="title">Title *</label>
<input
id="title"
type="text"
value.bind="formData.title"
placeholder="Enter todo title"
required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
value.bind="formData.description"
placeholder="Optional description"
rows="3"></textarea>
</div>
<div class="form-group">
<label for="category">Category</label>
<select id="category" value.bind="formData.category">
<option repeat.for="cat of categories" value.bind="cat">
${categoryLabels[cat]}
</option>
</select>
</div>
<button type="submit" disabled.bind="!isValid">
Add Todo
</button>
</form>
</div>import { bindable } from 'aurelia';
import { Todo, CATEGORY_LABELS, CATEGORY_COLORS } from './models';
export class TodoItem {
@bindable todo!: Todo;
@bindable onToggle?: (todo: Todo) => void;
@bindable onDelete?: (todo: Todo) => void;
categoryLabels = CATEGORY_LABELS;
categoryColors = CATEGORY_COLORS;
get categoryColor(): string {
return this.categoryColors[this.todo.category];
}
handleToggle(): void {
this.onToggle?.(this.todo);
}
handleDelete(): void {
if (confirm(`Delete "${this.todo.title}"?`)) {
this.onDelete?.(this.todo);
}
}
get formattedDate(): string {
return this.todo.createdAt.toLocaleDateString();
}
}<div class="todo-item ${todo.completed ? 'completed' : ''}">
<div class="todo-content">
<label class="todo-checkbox">
<input
type="checkbox"
checked.bind="todo.completed"
change.trigger="handleToggle()" />
<span class="checkmark"></span>
</label>
<div class="todo-details">
<h3 class="todo-title">${todo.title}</h3>
<p if.bind="todo.description" class="todo-description">
${todo.description}
</p>
<div class="todo-meta">
<span class="todo-category" style="background-color: ${categoryColor}">
${categoryLabels[todo.category]}
</span>
<span class="todo-date">${formattedDate}</span>
</div>
</div>
</div>
<button
class="delete-btn"
click.trigger="handleDelete()"
title="Delete todo">
×
</button>
</div><import from="./todo-form"></import>
<import from="./todo-item"></import>
<div class="app">
<header class="app-header">
<h1>📝 Aurelia Todo App</h1>
<div class="stats">
<span class="stat">Total: ${totalTodos}</span>
<span class="stat">Active: ${activeTodos}</span>
<span class="stat">Completed: ${completedTodos}</span>
</div>
</header>
<main class="app-main">
<div class="sidebar">
<todo-form on-submit.bind="(data) => addTodo(data)"></todo-form>
</div>
<div class="content">
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label>Category:</label>
<select value.bind="filterCategory">
<option value="all">All Categories</option>
<option repeat.for="cat of categories" value.bind="cat">
${cat === 'all' ? 'All' : cat}
</option>
</select>
</div>
<div class="filter-group">
<label>Status:</label>
<select value.bind="filterCompleted">
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
</div>
<button
if.bind="completedTodos > 0"
click.trigger="clearCompleted()"
class="clear-btn">
Clear Completed
</button>
</div>
<!-- Todo List -->
<div class="todo-list">
<div if.bind="filteredTodos.length === 0" class="empty-state">
<p>No todos found!</p>
<small if.bind="filterCategory !== 'all' || filterCompleted !== 'all'">
Try changing your filters
</small>
</div>
<todo-item
repeat.for="todo of filteredTodos; key: id"
todo.bind="todo"
on-toggle.bind="(todo) => toggleTodo(todo)"
on-delete.bind="(todo) => deleteTodo(todo)">
</todo-item>
</div>
</div>
</main>
</div>* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
background: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin-bottom: 1rem;
}
.stats {
display: flex;
gap: 2rem;
}
.stat {
font-size: 0.9rem;
color: #666;
}
.app-main {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
/* Todo Form */
.todo-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.todo-form h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filters */
.filters {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: flex-end;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-group {
flex: 1;
}
.filter-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.filter-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.clear-btn {
background: #ef4444;
}
.clear-btn:hover {
background: #dc2626;
}
/* Todo List */
.todo-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
background: white;
padding: 3rem;
border-radius: 8px;
text-align: center;
color: #999;
}
/* Todo Item */
.todo-item {
background: white;
padding: 1rem;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.todo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-content {
display: flex;
gap: 1rem;
flex: 1;
}
.todo-checkbox {
cursor: pointer;
position: relative;
}
.todo-checkbox input {
cursor: pointer;
}
.todo-details {
flex: 1;
}
.todo-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
}
.todo-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.todo-meta {
display: flex;
gap: 1rem;
align-items: center;
}
.todo-category {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: white;
font-weight: 500;
}
.todo-date {
font-size: 0.8rem;
color: #999;
}
.delete-btn {
background: transparent;
color: #ef4444;
border: 1px solid #ef4444;
width: 32px;
height: 32px;
padding: 0;
font-size: 1.5rem;
line-height: 1;
}
.delete-btn:hover {
background: #ef4444;
color: white;
}
@media (max-width: 768px) {
.app-main {
grid-template-columns: 1fr;
}
.filters {
flex-direction: column;
align-items: stretch;
}
}