build app
This commit is contained in:
parent
19ac294843
commit
479b41cb8f
119
README.md
119
README.md
@ -0,0 +1,119 @@
|
|||||||
|
# Angular ToDo-App
|
||||||
|
|
||||||
|
Eine einfache ToDo-Webanwendung als Lehrbeispiel für den Frontend-Unterricht.
|
||||||
|
|
||||||
|
## Technologien
|
||||||
|
|
||||||
|
| Tool | Zweck |
|
||||||
|
|------|-------|
|
||||||
|
| **Angular 21** | UI-Framework (Standalone Components) |
|
||||||
|
| **Vite** | Build-Tool & Dev-Server |
|
||||||
|
| **TypeScript** | Typsicheres JavaScript |
|
||||||
|
| **Fetch API** | HTTP-Requests (nativ im Browser, kein Zusatz-Paket) |
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
WebDev-Angular-ToDo-Example/
|
||||||
|
├── index.html ← HTML-Einstiegspunkt
|
||||||
|
├── vite.config.ts ← Vite-Konfiguration (mit Angular-Plugin)
|
||||||
|
├── tsconfig.json ← TypeScript-Konfiguration
|
||||||
|
├── package.json ← Abhängigkeiten & npm-Skripte
|
||||||
|
└── src/
|
||||||
|
├── main.ts ← App-Bootstrap (Startpunkt)
|
||||||
|
├── styles.css ← Globale CSS-Basis-Styles
|
||||||
|
└── app/
|
||||||
|
├── app.component.ts ← Root-Komponente (UI + Logik)
|
||||||
|
├── todo.model.ts ← TypeScript-Interfaces (Datentypen)
|
||||||
|
└── todo.service.ts ← Service für API-Kommunikation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation & Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Abhängigkeiten installieren
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Entwicklungsserver starten (mit Hot Reload)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Dev-Server läuft standardmässig unter **http://localhost:5173**.
|
||||||
|
|
||||||
|
## Build für Produktion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Die fertigen Dateien landen im `dist/`-Ordner und können auf jedem statischen Webserver deployed werden.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die App kommuniziert mit einer REST-API unter:
|
||||||
|
```
|
||||||
|
https://webdev.iten-web.ch/10003/api/
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App verwendet Best-Practice-Routing mit REST-Pfaden:
|
||||||
|
```
|
||||||
|
https://webdev.iten-web.ch/10003/api/todos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verwendete Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| `GET` | `/todos?page=1&limit=10` | Alle ToDos (paginiert) |
|
||||||
|
| `POST` | `/todos` | Neues ToDo erstellen |
|
||||||
|
| `PATCH` | `/todos/:id` | ToDo teilweise aktualisieren |
|
||||||
|
| `DELETE` | `/todos/:id` | ToDo löschen |
|
||||||
|
|
||||||
|
## Angular-Konzepte im Überblick
|
||||||
|
|
||||||
|
### Standalone Components
|
||||||
|
Seit Angular 14 können Komponenten ohne `NgModule` erstellt werden.
|
||||||
|
`standalone: true` im `@Component`-Decorator macht eine Komponente eigenständig.
|
||||||
|
|
||||||
|
### Data Binding
|
||||||
|
```html
|
||||||
|
<!-- Interpolation: Wert anzeigen -->
|
||||||
|
{{ todo.title }}
|
||||||
|
|
||||||
|
<!-- Property Binding: Attribut setzen -->
|
||||||
|
[checked]="todo.completed"
|
||||||
|
[disabled]="loading"
|
||||||
|
|
||||||
|
<!-- Event Binding: Methode aufrufen -->
|
||||||
|
(click)="deleteTodo(todo.id)"
|
||||||
|
(change)="toggleTodo(todo)"
|
||||||
|
|
||||||
|
<!-- Two-Way Binding: Lesen und Schreiben -->
|
||||||
|
[(ngModel)]="newTitle"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strukturdirektiven
|
||||||
|
```html
|
||||||
|
<!-- Element nur anzeigen, wenn Bedingung wahr ist -->
|
||||||
|
*ngIf="loading"
|
||||||
|
|
||||||
|
<!-- Element für jedes Element im Array wiederholen -->
|
||||||
|
*ngFor="let todo of todos"
|
||||||
|
|
||||||
|
<!-- CSS-Klasse bedingt hinzufügen -->
|
||||||
|
[class.completed]="todo.completed"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
Services werden im Konstruktor deklariert – Angular stellt die Instanz automatisch bereit:
|
||||||
|
```typescript
|
||||||
|
constructor(private todoService: TodoService) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Hooks
|
||||||
|
`ngOnInit()` wird einmalig nach der Initialisierung der Komponente aufgerufen:
|
||||||
|
```typescript
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadTodos(); // Daten beim Start laden
|
||||||
|
}
|
||||||
|
```
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Angular ToDo-App</title>
|
||||||
|
|
||||||
|
<!-- Globale CSS-Datei für Reset und Basis-Styles -->
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
app-root ist der Selector der Root-Komponente (AppComponent).
|
||||||
|
Angular ersetzt dieses Element durch den gerendereten Komponenten-Inhalt.
|
||||||
|
-->
|
||||||
|
<app-root></app-root>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Einstiegspunkt der Angular-Anwendung.
|
||||||
|
type="module" ist notwendig, damit der Browser ES-Module unterstützt.
|
||||||
|
-->
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13372
package-lock.json
generated
Normal file
13372
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "webdev-angular-todo-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^21.2.13",
|
||||||
|
"@angular/compiler": "^21.2.13",
|
||||||
|
"@angular/core": "^21.2.13",
|
||||||
|
"@angular/forms": "^21.2.13",
|
||||||
|
"@angular/platform-browser": "^21.2.13",
|
||||||
|
"zone.js": "^0.16.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@analogjs/vite-plugin-angular": "^2.5.1",
|
||||||
|
"@angular-devkit/build-angular": "^21.2.11",
|
||||||
|
"@angular/compiler-cli": "^21.2.13",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^6.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/app/app.component.ts
Normal file
466
src/app/app.component.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
// app.component.ts – Root-Komponente der Anwendung
|
||||||
|
//
|
||||||
|
// Eine Angular-Komponente besteht aus drei Teilen:
|
||||||
|
// 1. @Component-Decorator: Metadaten (Selector, Template, Styles)
|
||||||
|
// 2. Klasse: Eigenschaften (Daten) und Methoden (Logik)
|
||||||
|
// 3. Template: HTML mit Angular-Syntax (Data Binding, Direktiven)
|
||||||
|
//
|
||||||
|
// STANDALONE-KOMPONENTE (modern, seit Angular 14):
|
||||||
|
// Kein NgModule notwendig. Abhängigkeiten (z. B. CommonModule, FormsModule)
|
||||||
|
// werden direkt im imports-Array der Komponente angegeben.
|
||||||
|
|
||||||
|
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common'; // *ngIf, *ngFor, etc.
|
||||||
|
import { FormsModule } from '@angular/forms'; // [(ngModel)] Two-Way-Binding
|
||||||
|
import { TodoService } from './todo.service';
|
||||||
|
import { Todo } from './todo.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
// selector: Wie diese Komponente im HTML eingebunden wird (<app-root>)
|
||||||
|
selector: 'app-root',
|
||||||
|
|
||||||
|
// standalone: true → kein NgModule nötig (moderner Angular-Ansatz)
|
||||||
|
standalone: true,
|
||||||
|
|
||||||
|
// imports: Andere Angular-Module/Komponenten, die im Template verwendet werden
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
|
||||||
|
// template: Das HTML-Template der Komponente (inline, kein separates File)
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>ToDo-App</h1>
|
||||||
|
<p class="subtitle">Angular 21 · Vite · REST-API</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======================================================
|
||||||
|
FEHLERMELDUNG
|
||||||
|
*ngIf zeigt das Element nur an, wenn "error" einen Wert hat.
|
||||||
|
====================================================== -->
|
||||||
|
<div class="error-box" *ngIf="error">
|
||||||
|
<strong>Fehler:</strong> {{ error }}
|
||||||
|
<button class="close-btn" (click)="error = ''">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================================================
|
||||||
|
FORMULAR – Neues ToDo hinzufügen
|
||||||
|
(ngSubmit): Ruft addTodo() beim Absenden auf.
|
||||||
|
[(ngModel)]: Two-Way-Binding – verbindet Input mit "newTitle".
|
||||||
|
[disabled]: Deaktiviert Button, wenn Feld leer ist.
|
||||||
|
====================================================== -->
|
||||||
|
<form class="add-form" (ngSubmit)="addTodo()">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newTitle"
|
||||||
|
name="newTitle"
|
||||||
|
placeholder="Neues ToDo eingeben..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" [disabled]="!newTitle.trim() || saving">
|
||||||
|
{{ saving ? 'Speichert…' : '+ Hinzufügen' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- ======================================================
|
||||||
|
LADEINDIKATOR
|
||||||
|
Wird angezeigt, während Daten von der API geladen werden.
|
||||||
|
====================================================== -->
|
||||||
|
<div class="loading" *ngIf="loading">Daten werden geladen…</div>
|
||||||
|
|
||||||
|
<!-- ======================================================
|
||||||
|
TODO-LISTE
|
||||||
|
*ngFor iteriert über das todos-Array und rendert ein <li> pro Eintrag.
|
||||||
|
"let todo of todos" → todo ist die aktuelle Variable im Loop.
|
||||||
|
[class.completed]: Fügt CSS-Klasse hinzu, wenn todo.completed === true.
|
||||||
|
====================================================== -->
|
||||||
|
<ul class="todo-list" *ngIf="!loading">
|
||||||
|
|
||||||
|
<!-- Meldung, wenn keine Todos vorhanden -->
|
||||||
|
<li class="empty-state" *ngIf="todos.length === 0">
|
||||||
|
Keine ToDos auf dieser Seite.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li
|
||||||
|
*ngFor="let todo of todos"
|
||||||
|
class="todo-item"
|
||||||
|
[class.completed]="todo.completed"
|
||||||
|
>
|
||||||
|
<!-- Checkbox: (change) reagiert auf Änderung und ruft toggleTodo() auf -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="todo.completed"
|
||||||
|
(change)="toggleTodo(todo)"
|
||||||
|
[id]="'todo-' + todo.id"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Label verknüpft Text mit der Checkbox über das id-Attribut -->
|
||||||
|
<label [for]="'todo-' + todo.id" class="todo-title">
|
||||||
|
{{ todo.title }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Badge zeigt die User-ID an -->
|
||||||
|
<span class="user-badge">User {{ todo.userId }}</span>
|
||||||
|
|
||||||
|
<!-- Löschen-Button: (click) ruft deleteTodo() mit der ID auf -->
|
||||||
|
<button
|
||||||
|
class="delete-btn"
|
||||||
|
(click)="deleteTodo(todo.id)"
|
||||||
|
title="ToDo löschen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- ======================================================
|
||||||
|
PAGINATION
|
||||||
|
[disabled]: Deaktiviert Buttons an den Rändern der Liste.
|
||||||
|
====================================================== -->
|
||||||
|
<div class="pagination" *ngIf="!loading">
|
||||||
|
<button
|
||||||
|
(click)="prevPage()"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="page-info">Seite {{ currentPage }}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
(click)="nextPage()"
|
||||||
|
[disabled]="todos.length < pageSize"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
|
||||||
|
// styles: Komponenten-spezifisches CSS (ist automatisch auf diese Komponente begrenzt)
|
||||||
|
styles: [`
|
||||||
|
/* ── Layout ──────────────────────────────────────────── */
|
||||||
|
.container {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-family: inherit; /* übernimmt Font aus styles.css */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────── */
|
||||||
|
.app-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fehlermeldung ───────────────────────────────────── */
|
||||||
|
.error-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #b91c1c;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formular ────────────────────────────────────────── */
|
||||||
|
.add-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.add-form input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.add-form input:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.add-form button {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.add-form button:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.add-form button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ladeindikator ───────────────────────────────────── */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ToDo-Liste ──────────────────────────────────────── */
|
||||||
|
.todo-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.todo-item:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abgehaktes ToDo: Text wird durchgestrichen und ausgegraut */
|
||||||
|
.todo-item.completed .todo-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item input[type="checkbox"] {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.todo-title {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ──────────────────────────────────────── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.page-info {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit {
|
||||||
|
|
||||||
|
// ── Zustand (State) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Die aktuell angezeigte Liste von ToDos */
|
||||||
|
todos: Todo[] = [];
|
||||||
|
|
||||||
|
/** Wird true, während die ToDo-Liste von der API geladen wird */
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
/** Wird true, während ein neues ToDo gespeichert wird */
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
/** Enthält eine Fehlermeldung, falls ein API-Aufruf fehlschlägt */
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
/** Eingabefeld-Wert für das "Neues ToDo"-Formular (Two-Way-Binding mit ngModel) */
|
||||||
|
newTitle = '';
|
||||||
|
|
||||||
|
/** Aktuelle Seite der Pagination (1-basiert) */
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
/** Anzahl der ToDos pro Seite */
|
||||||
|
readonly pageSize = 10;
|
||||||
|
|
||||||
|
// ── Dependency Injection ───────────────────────────────────────────────────
|
||||||
|
// Angular erkennt den Parameter-Typ und stellt automatisch die richtige
|
||||||
|
// Instanz des Services bereit (Inversion of Control).
|
||||||
|
constructor(
|
||||||
|
private todoService: TodoService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Lifecycle-Hook ─────────────────────────────────────────────────────────
|
||||||
|
// ngOnInit() wird einmalig aufgerufen, nachdem die Komponente initialisiert wurde.
|
||||||
|
// Ideal für den ersten Datenabruf.
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Methoden ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die ToDo-Liste von der API für die aktuelle Seite.
|
||||||
|
* async/await macht asynchronen Code lesbar wie synchronen Code.
|
||||||
|
*/
|
||||||
|
async loadTodos(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
this.todos = await this.todoService.getTodos(this.currentPage, this.pageSize);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
|
this.error = `Die ToDos konnten nicht geladen werden. ${message}`;
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
// finally wird immer ausgeführt (auch bei Fehler)
|
||||||
|
this.loading = false;
|
||||||
|
// Stellt sicher, dass die UI sofort aktualisiert wird,
|
||||||
|
// auch wenn der Async-Callback ausserhalb der Angular-Zone landet.
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein neues ToDo via POST-Request und fügt es der Liste hinzu.
|
||||||
|
* unshift() fügt das neue Element am Anfang des Arrays ein.
|
||||||
|
*/
|
||||||
|
async addTodo(): Promise<void> {
|
||||||
|
const title = this.newTitle.trim();
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const created = await this.todoService.createTodo({ userId: 1, title });
|
||||||
|
this.todos.unshift(created); // am Anfang der Liste einfügen
|
||||||
|
this.newTitle = ''; // Eingabefeld leeren
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Das ToDo konnte nicht erstellt werden.';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt den completed-Status eines ToDos via PATCH-Request.
|
||||||
|
* Partial<Todo> erlaubt es, nur einzelne Felder zu übergeben.
|
||||||
|
*/
|
||||||
|
async toggleTodo(todo: Todo): Promise<void> {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const updated = await this.todoService.patchTodo(todo.id, {
|
||||||
|
completed: !todo.completed,
|
||||||
|
});
|
||||||
|
// Das aktualisierte Objekt im lokalen Array ersetzen
|
||||||
|
const index = this.todos.findIndex(t => t.id === todo.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.todos[index] = updated;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Der Status konnte nicht geändert werden.';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht ein ToDo via DELETE-Request und entfernt es aus der lokalen Liste.
|
||||||
|
* filter() erstellt ein neues Array ohne das gelöschte Element.
|
||||||
|
*/
|
||||||
|
async deleteTodo(id: number): Promise<void> {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await this.todoService.deleteTodo(id);
|
||||||
|
this.todos = this.todos.filter(t => t.id !== id);
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Das ToDo konnte nicht gelöscht werden.';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blättert zur vorherigen Seite */
|
||||||
|
prevPage(): void {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.currentPage--;
|
||||||
|
this.loadTodos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blättert zur nächsten Seite */
|
||||||
|
nextPage(): void {
|
||||||
|
this.currentPage++;
|
||||||
|
this.loadTodos();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/todo.model.ts
Normal file
19
src/app/todo.model.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// todo.model.ts – TypeScript-Interfaces für die ToDo-Datenstrukturen
|
||||||
|
//
|
||||||
|
// Interfaces beschreiben den Aufbau von Objekten zur Compile-Zeit.
|
||||||
|
// Sie erzeugen keinen JavaScript-Code, sondern dienen nur der Typprüfung.
|
||||||
|
|
||||||
|
/** Ein vollständiges ToDo-Objekt, wie es die API zurückgibt */
|
||||||
|
export interface Todo {
|
||||||
|
id: number; // Eindeutige ID (von der API vergeben)
|
||||||
|
userId: number; // ID des Benutzers, dem das ToDo gehört
|
||||||
|
title: string; // Titel / Beschreibung des ToDos
|
||||||
|
completed: boolean; // Erledigt-Status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daten zum Erstellen eines neuen ToDos (id wird von der API gesetzt) */
|
||||||
|
export interface TodoCreate {
|
||||||
|
userId: number;
|
||||||
|
title: string;
|
||||||
|
completed?: boolean; // Optional: Standard ist false
|
||||||
|
}
|
||||||
121
src/app/todo.service.ts
Normal file
121
src/app/todo.service.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// todo.service.ts – Service für die Kommunikation mit der REST-API
|
||||||
|
//
|
||||||
|
// Ein Angular-Service ist eine Klasse, die wiederverwendbare Logik enthält.
|
||||||
|
// @Injectable({ providedIn: 'root' }) registriert den Service als Singleton
|
||||||
|
// im gesamten App-Kontext (nur eine Instanz für die ganze Anwendung).
|
||||||
|
//
|
||||||
|
// API-Dokumentation (OpenAPI): https://webdev.iten-web.ch/10003/api/
|
||||||
|
//
|
||||||
|
// Für diese App verwenden wir REST-Pfade im Best-Practice-Stil:
|
||||||
|
// Basis-URL: https://webdev.iten-web.ch/10003/api
|
||||||
|
// Beispiel: https://webdev.iten-web.ch/10003/api/todos?page=1&limit=10
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Todo, TodoCreate } from './todo.model';
|
||||||
|
|
||||||
|
// Basis-URL der API.
|
||||||
|
const API_BASE = 'https://webdev.iten-web.ch/10003/api';
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TodoService {
|
||||||
|
|
||||||
|
private buildUrl(path: string, query: Record<string, string | number | boolean> = {}): string {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const url = new URL(`${API_BASE}${normalizedPath}`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, init?: RequestInit, query: Record<string, string | number | boolean> = {}): Promise<T> {
|
||||||
|
const url = this.buildUrl(path, query);
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fehler ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Promise.race garantiert, dass der Aufruf auch dann endet,
|
||||||
|
// wenn fetch in seltenen Browser-/Extension-Faellen hängen bleibt.
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new Error('Zeitüberschreitung: Die API hat nicht rechtzeitig geantwortet.'));
|
||||||
|
}, REQUEST_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.race([fetchPromise, timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw new Error('Zeitüberschreitung: Die API hat nicht rechtzeitig geantwortet.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle ToDos abrufen – GET /todos
|
||||||
|
* Unterstützt serverseitige Pagination (Seite + Einträge pro Seite).
|
||||||
|
*/
|
||||||
|
async getTodos(page = 1, limit = 10): Promise<Todo[]> {
|
||||||
|
return this.request<Todo[]>('/todos', undefined, { page, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neues ToDo anlegen – POST /todos
|
||||||
|
* Der Request-Body enthält userId und title (completed ist optional).
|
||||||
|
*/
|
||||||
|
async createTodo(data: TodoCreate): Promise<Todo> {
|
||||||
|
return this.request<Todo>('/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToDo teilweise aktualisieren – PATCH /todos/:id
|
||||||
|
* Nur die angegebenen Felder werden geändert (z. B. nur "completed").
|
||||||
|
* Im Gegensatz zu PUT wird das Objekt nicht vollständig ersetzt.
|
||||||
|
*/
|
||||||
|
async patchTodo(id: number, changes: Partial<Todo>): Promise<Todo> {
|
||||||
|
return this.request<Todo>(`/todos/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToDo löschen – DELETE /todos/:id
|
||||||
|
* Die API antwortet mit HTTP 204 (No Content) – kein Body.
|
||||||
|
*/
|
||||||
|
async deleteTodo(id: number): Promise<void> {
|
||||||
|
await this.request<void>(`/todos/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// main.ts – Einstiegspunkt der Angular-Anwendung
|
||||||
|
//
|
||||||
|
// bootstrapApplication() startet eine "standalone" Angular-App ohne NgModule.
|
||||||
|
// Dies ist der moderne Ansatz seit Angular 14 (empfohlen ab Angular 17).
|
||||||
|
|
||||||
|
import 'zone.js';
|
||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent).catch(err => console.error(err));
|
||||||
24
src/styles.css
Normal file
24
src/styles.css
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/* styles.css – Globale Basis-Styles
|
||||||
|
*
|
||||||
|
* Diese Datei wird in index.html verlinkt und gilt für die gesamte App.
|
||||||
|
* Komponenten-spezifische Styles werden direkt in der jeweiligen Komponente definiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── CSS Reset / Basis ──────────────────────────────────────────────────────
|
||||||
|
Box-sizing: border-box sorgt dafür, dass Padding/Border in die Breite eingerechnet wird.
|
||||||
|
Das vereinfacht Layout-Berechnungen erheblich.
|
||||||
|
*/
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schrift, Hintergrund und Standard-Abstände */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Avenir Next', 'Trebuchet MS', 'Segoe UI', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111827;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
18
tsconfig.app.json
Normal file
18
tsconfig.app.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// tsconfig.app.json – Konfiguration speziell für das App-Kompilat
|
||||||
|
// Wird von @analogjs/vite-plugin-angular gesucht und verwendet.
|
||||||
|
// Erbt alle Einstellungen aus der Basis-tsconfig.json.
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
// Nur relevante Verzeichnisse einschliessen
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
// Explizit den Einstiegspunkt angeben
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// TypeScript-Konfiguration für Angular 17 mit Vite
|
||||||
|
"compilerOptions": {
|
||||||
|
// Ziel-JavaScript-Version (ES2022 unterstützt moderne Features wie Top-Level await)
|
||||||
|
"target": "ES2022",
|
||||||
|
// Wichtig für Angular-Decorators: Klasseneigenschaften werden mit Object.defineProperty gesetzt
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
// ES-Module als Modulformat (erforderlich für Vite)
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
// Verfügbare globale APIs
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
// Strikte Typprüfung aktivieren (empfohlen für bessere Code-Qualität)
|
||||||
|
"strict": true,
|
||||||
|
// Aktiviert TypeScript-Decorators (@Component, @Injectable, etc.)
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
// Angular Ivy benötigt KEIN emitDecoratorMetadata (im Gegensatz zu Angular 8 und früher)
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
// Bibliotheks-Typen nicht prüfen (verhindert Fehler in node_modules)
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
// Nur Dateien im src-Verzeichnis kompilieren
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
// Konfiguration für Vite als Build-Tool mit Angular-Unterstützung
|
||||||
|
//
|
||||||
|
// @analogjs/vite-plugin-angular ist ein offizielles Vite-Plugin,
|
||||||
|
// das den Angular-Compiler (Ivy) in den Vite-Build-Prozess integriert.
|
||||||
|
// Es ermöglicht Hot Module Replacement (HMR) und schnelle Builds.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Das Angular-Plugin kompiliert .ts-Dateien mit Angular-Decorators
|
||||||
|
// und HTML-Templates sowie CSS-Styles in Komponenten
|
||||||
|
plugins: [angular()],
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user