complete back up from Project manager
This commit is contained in:
parent
90585b4eae
commit
e3616cc22e
43 changed files with 3880 additions and 0 deletions
41
.env.example
Normal file
41
.env.example
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# --- Flask Core ---
|
||||||
|
# Geheimer Schlüssel für Session-Signierung. Erzeugen z.B. mit:
|
||||||
|
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=change-me-to-a-random-secret
|
||||||
|
|
||||||
|
# Umgebung: development | production
|
||||||
|
FLASK_ENV=development
|
||||||
|
|
||||||
|
# Domain/Host (erst final setzen, wenn Domain feststeht). Beispiel: hub.example.com
|
||||||
|
# Leer lassen für lokale Entwicklung (IP:Port).
|
||||||
|
SERVER_NAME=
|
||||||
|
|
||||||
|
# --- Datenbank ---
|
||||||
|
# SQLite-Pfad. Standard: instance/hub.sqlite (relativ zur instance/-Ordner).
|
||||||
|
# Für Postgres später z.B.: postgresql://user:pass@localhost/hub
|
||||||
|
DATABASE_URL=sqlite:///hub.sqlite
|
||||||
|
|
||||||
|
# --- Auth / Self-Registration ---
|
||||||
|
# Komma-getrennte Allowlist erlaubter E-Mail-Adressen (vorerst nur eine).
|
||||||
|
ALLOWED_EMAILS=deine@adresse.com
|
||||||
|
|
||||||
|
# Gültigkeitsdauer des Login-Codes in Minuten.
|
||||||
|
LOGIN_CODE_TTL_MINUTES=10
|
||||||
|
|
||||||
|
# Rate-Limit für Code-Anfragen (Flask-Limiter Syntax), z.B. "5 per hour".
|
||||||
|
LOGIN_CODE_RATE_LIMIT=5 per hour
|
||||||
|
|
||||||
|
# Speicher-Backend für Rate-Limits.
|
||||||
|
# Dev/Einzelworker: memory:// | Produktion (mehrere gunicorn-Worker): redis://localhost:6379
|
||||||
|
RATELIMIT_STORAGE_URI=memory://
|
||||||
|
|
||||||
|
# --- SMTP / Mailversand ---
|
||||||
|
# Anbieter frei wechselbar (Proton Bridge lokal, oder EU-Relay wie Infomaniak/Mailjet).
|
||||||
|
MAIL_SERVER=127.0.0.1
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USE_TLS=false
|
||||||
|
MAIL_USE_SSL=false
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
# Absenderadresse der Login-Mails.
|
||||||
|
MAIL_DEFAULT_SENDER=hub@example.com
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Vendored Schriftdateien als Binär behandeln (kein Diff/Textmerge).
|
||||||
|
*.ttf binary
|
||||||
213
CLAUDE.md
Normal file
213
CLAUDE.md
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
# CLAUDE.md — Projekt-Hub
|
||||||
|
|
||||||
|
Diese Datei beschreibt den Kontext, die Architektur und die Konventionen für dieses Projekt.
|
||||||
|
Sie dient Claude (und anderen Entwicklern) als Einstiegspunkt, bevor Code geschrieben wird.
|
||||||
|
|
||||||
|
## Hintergrund / Idee
|
||||||
|
|
||||||
|
Der Nutzer hat ADHS und führt ein analoges Bullet Journal (Logbuch, Weekly Bridge, Monthly
|
||||||
|
Log). Projektbezogene Arbeitsaufgaben sollen davon getrennt digital verwaltet werden.
|
||||||
|
|
||||||
|
Ein erster Prototyp war eine einzelne HTML-Datei mit LocalStorage (Projekte, Aufgaben,
|
||||||
|
Fälligkeitsdaten, Export/Import als JSON). Diese Datei dient als **funktionale Referenz**
|
||||||
|
für Feature-Set und UI/UX — siehe `reference/projekte.html` (wird vom Nutzer bereitgestellt).
|
||||||
|
|
||||||
|
Das Ziel jetzt: aus dieser Idee eine **Flask-Webapp** machen, die auf einem eigenen VPS
|
||||||
|
läuft (volle Datenhoheit, kein Drittanbieter-Cloud-Dienst). Diese App ist der erste Baustein
|
||||||
|
eines **Hubs**, der später um weitere kleine Apps/Module erweitert wird — alle hinter einem
|
||||||
|
gemeinsamen Login.
|
||||||
|
|
||||||
|
## Kernprinzipien
|
||||||
|
|
||||||
|
1. **Datenschutz / Selbsthosting**: Alle Daten bleiben auf dem eigenen VPS. Keine externen
|
||||||
|
Tracker, kein CDN-Zwang für kritische Funktionen (Fallback ohne Internet sollte
|
||||||
|
funktionieren oder zumindest nicht hart brechen).
|
||||||
|
2. **ADHS-freundlich**: Niedrigschwellig, wenig Klicks, schnelles Erfassen ("Brain dump"),
|
||||||
|
sofortiges visuelles Feedback (Fälligkeits-Badges: überfällig/bald/ok), keine
|
||||||
|
überladenen Screens.
|
||||||
|
3. **Erweiterbarkeit**: Architektur so anlegen, dass weitere "Mini-Apps" als eigene
|
||||||
|
Module/Blueprints unter demselben Login + derselben Navigation ergänzt werden können,
|
||||||
|
ohne den Kern umzubauen.
|
||||||
|
4. **Einfacher Login**: Kein Passwort. Login per E-Mail-Adresse + zeitlich begrenztem
|
||||||
|
Code (10 Minuten gültig), der per Mail zugeschickt wird ("Magic Code" / OTP-Login).
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- **Backend**: Python, Flask
|
||||||
|
- **Datenbank**: SQLite (ausreichend für Einzelnutzer/kleine Gruppe; Pfad konfigurierbar,
|
||||||
|
später ggf. auf Postgres migrierbar — daher SQLAlchemy als ORM verwenden)
|
||||||
|
- **Auth**: Flask-Login für Session-Verwaltung + eigene Logik für E-Mail-Code-Versand
|
||||||
|
(Flask-Mail oder smtplib, SMTP-Zugangsdaten über Umgebungsvariablen)
|
||||||
|
- **Frontend**: Server-seitig gerenderte Templates (Jinja2) + minimal JavaScript für
|
||||||
|
interaktive Elemente (Checkbox-Toggle, Filter, evtl. htmx für dynamische Updates ohne
|
||||||
|
vollständigen Page-Reload). Kein schweres SPA-Framework — Ladezeit und Einfachheit
|
||||||
|
haben Priorität.
|
||||||
|
- **Styling**: Eigenes CSS im Stil des Prototyps (Dark Theme, siehe Referenzdatei) als
|
||||||
|
Basis-Theme für den ganzen Hub, das von allen Modulen genutzt wird.
|
||||||
|
- **Deployment**: VPS, lauffähig hinter einem Reverse Proxy (nginx) via gunicorn.
|
||||||
|
`.env`-Datei für Secrets (SMTP, SECRET_KEY, Datenbankpfad). `requirements.txt` pflegen.
|
||||||
|
|
||||||
|
## Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
projekt-hub/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py # App-Factory, Extensions-Init, Blueprint-Registrierung
|
||||||
|
│ ├── config.py # Config-Klassen (liest aus .env)
|
||||||
|
│ ├── extensions.py # db, login_manager, mail etc.
|
||||||
|
│ ├── models.py # User, LoginCode (gemeinsame Modelle)
|
||||||
|
│ ├── auth/ # Auth-Modul (Blueprint)
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── routes.py # /login, /verify, /logout
|
||||||
|
│ │ └── forms.py # E-Mail-Eingabe, Code-Eingabe
|
||||||
|
│ ├── projects/ # Modul 1: Projekte & Aufgaben (aus dem Prototyp)
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── routes.py
|
||||||
|
│ │ ├── models.py # Project, Task
|
||||||
|
│ │ └── templates/projects/
|
||||||
|
│ ├── templates/
|
||||||
|
│ │ ├── base.html # gemeinsames Layout, Navigation, Theme
|
||||||
|
│ │ └── auth/
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ └── verify.html
|
||||||
|
│ └── static/
|
||||||
|
│ └── css/main.css # Dark-Theme-Basis aus dem Prototyp
|
||||||
|
├── migrations/ # Flask-Migrate / Alembic
|
||||||
|
├── instance/ # SQLite-Datei (nicht versionieren)
|
||||||
|
├── .env.example
|
||||||
|
├── requirements.txt
|
||||||
|
├── wsgi.py
|
||||||
|
└── run.py # lokaler Dev-Server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erweiterbarkeit für künftige Module
|
||||||
|
|
||||||
|
Jedes neue Mini-App-Modul:
|
||||||
|
- ist ein eigenes Python-Package mit eigenem Blueprint (`app/<modulname>/`)
|
||||||
|
- bringt eigene Models, Routes, Templates mit (Templates unter `templates/<modulname>/`)
|
||||||
|
- wird in `app/__init__.py` per `app.register_blueprint(...)` eingebunden
|
||||||
|
- erscheint automatisch als Eintrag in der gemeinsamen Navigation (`base.html`), idealerweise
|
||||||
|
über eine zentrale Liste/Registry, die Modulname + URL + Icon enthält
|
||||||
|
- nutzt denselben `current_user` (Flask-Login) — kein eigenes Auth nötig
|
||||||
|
|
||||||
|
Ziel: ein neues Modul hinzufügen = neuen Ordner anlegen, Blueprint registrieren,
|
||||||
|
Navigationseintrag ergänzen. Kern-App, Auth und Theme bleiben unangetastet.
|
||||||
|
|
||||||
|
## Datenmodell (Modul "Projekte")
|
||||||
|
|
||||||
|
**User**
|
||||||
|
- id
|
||||||
|
- email (unique)
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
**LoginCode**
|
||||||
|
- id
|
||||||
|
- user_id (FK)
|
||||||
|
- code (6-stellig, numerisch)
|
||||||
|
- created_at
|
||||||
|
- expires_at (created_at + 10 Minuten)
|
||||||
|
- used (bool)
|
||||||
|
|
||||||
|
**Project**
|
||||||
|
- id
|
||||||
|
- user_id (FK)
|
||||||
|
- name
|
||||||
|
- color (Hex-Wert, aus fester Palette wie im Prototyp)
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
**Task**
|
||||||
|
- id
|
||||||
|
- user_id (FK)
|
||||||
|
- project_id (FK, nullable — Aufgaben ohne Projekt erlaubt)
|
||||||
|
- text
|
||||||
|
- due_date (nullable)
|
||||||
|
- done (bool, default False)
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## Auth-Flow (E-Mail-Code-Login)
|
||||||
|
|
||||||
|
1. Nutzer gibt E-Mail-Adresse auf `/login` ein.
|
||||||
|
2. Backend prüft, ob User mit dieser E-Mail existiert — falls nicht, wird er angelegt
|
||||||
|
(passwortlose Self-Registration; ggf. später auf Allowlist beschränkbar für
|
||||||
|
Einzelnutzer-Setup).
|
||||||
|
3. Backend generiert 6-stelligen Code, speichert Hash + `expires_at = now + 10min`,
|
||||||
|
sendet Code per E-Mail (Betreff klar, kurzer Text, kein HTML-Tracking).
|
||||||
|
4. Nutzer landet auf `/verify`, gibt Code ein.
|
||||||
|
5. Backend prüft: Code korrekt, nicht abgelaufen, nicht bereits benutzt → Login via
|
||||||
|
Flask-Login (`login_user`), Code als `used` markieren.
|
||||||
|
6. Alte/abgelaufene Codes regelmäßig aufräumen (z.B. bei jedem neuen Code-Request für
|
||||||
|
denselben User).
|
||||||
|
7. Rate-Limiting: max. X Code-Anfragen pro E-Mail pro Stunde, um Spam/Missbrauch zu
|
||||||
|
verhindern (z.B. Flask-Limiter).
|
||||||
|
|
||||||
|
Sicherheitsaspekte:
|
||||||
|
- Code wird gehasht gespeichert (nicht im Klartext in der DB).
|
||||||
|
- Session-Cookie: `Secure`, `HttpOnly`, `SameSite=Lax`.
|
||||||
|
- HTTPS wird vom Reverse Proxy (nginx + Let's Encrypt) terminiert — App selbst geht von
|
||||||
|
HTTPS aus (`PREFERRED_URL_SCHEME = 'https'`).
|
||||||
|
|
||||||
|
## Feature-Set Modul "Projekte" (aus dem Prototyp übernehmen)
|
||||||
|
|
||||||
|
- Projekte anlegen/löschen, mit fester Farbpalette (zyklisch zugewiesen)
|
||||||
|
- Aufgaben anlegen mit: Text, optionalem Projekt, optionalem Fälligkeitsdatum
|
||||||
|
- Aufgaben abhaken (done/undone), löschen
|
||||||
|
- Fälligkeits-Badges: überfällig (rot), diese Woche (orange), normal (grün), kein Datum
|
||||||
|
- Ansicht "Nach Projekt" (gruppiert, mit Klapp-/Zähl-Funktion) + Ansicht "Alle Aufgaben"
|
||||||
|
(gefiltert: alle / offen / überfällig / diese Woche / erledigt)
|
||||||
|
- Statistik-Leiste (offen / erledigt / überfällig / Anzahl Projekte)
|
||||||
|
- Da Daten jetzt serverseitig in SQLite liegen und auf dem VPS gesichert werden,
|
||||||
|
entfällt der JSON-Export/Import als Sync-/Backup-Mechanismus. Stattdessen:
|
||||||
|
**PDF-Export** — eine projektbasierte To-Do-Liste zum Ausdrucken (offene und
|
||||||
|
erledigte Aufgaben, erledigte abgehakt/durchgestrichen, Aufgaben ohne Projekt in
|
||||||
|
eigenem Abschnitt). Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten);
|
||||||
|
DejaVu-Schrift im Repo mitgeliefert für identisches Rendering auf dem VPS.
|
||||||
|
|
||||||
|
## Nicht-Ziele (für jetzt)
|
||||||
|
|
||||||
|
- Keine Multi-Tenant-Mandantentrennung über User hinaus (Familien-/Team-Sharing später,
|
||||||
|
falls gewünscht)
|
||||||
|
- Kein OAuth/Social Login
|
||||||
|
- Kein Offline-First/PWA-Sync (kann später ergänzt werden)
|
||||||
|
- Kein Rich-Text/Markdown in Aufgaben (bewusst simpel halten)
|
||||||
|
|
||||||
|
## Nächste Schritte (Vorschlag für Umsetzungsreihenfolge)
|
||||||
|
|
||||||
|
1. Projektgerüst (App-Factory, Config, `.env.example`, `requirements.txt`)
|
||||||
|
2. Models + DB-Migration (User, LoginCode, Project, Task)
|
||||||
|
3. Auth-Modul (E-Mail-Code-Login, Templates für `login.html` / `verify.html`)
|
||||||
|
4. Basis-Layout `base.html` mit Navigation + Dark-Theme-CSS (aus Prototyp übernehmen)
|
||||||
|
5. Modul "Projekte": Routes + Templates, 1:1-Funktionsumfang aus dem HTML-Prototyp
|
||||||
|
6. PDF-Export (projektbasierte To-Do-Liste zum Ausdrucken, fpdf2)
|
||||||
|
7. Deployment-Vorbereitung: `wsgi.py`, gunicorn-Konfiguration, Beispiel-nginx-Config,
|
||||||
|
systemd-Service-Datei
|
||||||
|
|
||||||
|
## Entscheidungen (geklärt)
|
||||||
|
|
||||||
|
- **SMTP-Anbieter**: Proton Mail (eigene Adresse). Da Proton Mail Bridge eine
|
||||||
|
Desktop-Anwendung ist und nur mit zahlendem Plan läuft, wird sie testweise auf dem
|
||||||
|
VPS via Docker betrieben (lokaler SMTP-Relay auf `127.0.0.1`). Falls das auf dem
|
||||||
|
Server unzuverlässig läuft, ist ein **Fallback auf einen EU-SMTP-Relay-Dienst**
|
||||||
|
(z.B. Infomaniak, Mailjet EU-Region) als austauschbare Alternative vorgesehen — die
|
||||||
|
App liest SMTP-Host/Port/User/Passwort ausschließlich aus `.env`, sodass der Anbieter
|
||||||
|
ohne Codeänderung wechselbar ist.
|
||||||
|
- **Self-Registration**: Vorerst nur **eine** erlaubte E-Mail-Adresse (Allowlist mit
|
||||||
|
einem Eintrag in `.env`, z.B. `ALLOWED_EMAILS=deine@adresse.com`). Spätere
|
||||||
|
Erweiterung auf mehrere Adressen ist durch eine kommagetrennte Liste oder eigene
|
||||||
|
DB-Tabelle vorbereitet, aber jetzt nicht nötig.
|
||||||
|
- **Domain**: Noch nicht final. App wird so konfiguriert, dass Domain/Host über `.env`
|
||||||
|
(`SERVER_NAME` bzw. Reverse-Proxy-Konfiguration) gesetzt wird. Bis zur
|
||||||
|
Domain-Entscheidung ist die App über IP:Port oder eine Subdomain-Platzhalter
|
||||||
|
erreichbar; Cookie-Settings (`SESSION_COOKIE_DOMAIN`) werden erst final gesetzt,
|
||||||
|
wenn die Domain feststeht.
|
||||||
|
|
||||||
|
## Referenzdatei
|
||||||
|
|
||||||
|
`reference/projekte.html` — der funktionsfähige HTML/JS-Prototyp (Dark Theme,
|
||||||
|
LocalStorage, Export/Import). Dient als Vorlage für:
|
||||||
|
- Farbpalette und CSS-Variablen (Basis für `static/css/main.css`)
|
||||||
|
- UI-Struktur (Stats-Leiste, Formular, Projekt-Karten, Aufgabenliste, Filter-Buttons)
|
||||||
|
- Fälligkeits-Badge-Logik (`dueBadge`-Funktion → als Jinja-Filter oder Python-Helper
|
||||||
|
übernehmen)
|
||||||
|
- Datenmodell-Felder (Project: id/name/color; Task: id/text/project/due/done)
|
||||||
|
|
||||||
|
|
||||||
364
DEPLOYMENT.md
Normal file
364
DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
# DEPLOYMENT.md — Projekt-Hub auf dem VPS installieren
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt, wie die fertige Flask-App (siehe `CLAUDE.md` und
|
||||||
|
`TASKLISTE.md`) auf einem Ubuntu-VPS produktiv läuft. Zielarchitektur:
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → nginx (Port 80/443, TLS) → gunicorn (Unix-Socket) → Flask-App → SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd sorgt dafür, dass die App automatisch beim Booten startet und bei einem Absturz neu startet.
|
||||||
|
|
||||||
|
Vorausgesetzt: Ubuntu-VPS mit SSH-Zugang und sudo-Rechten. Domain ist optional —
|
||||||
|
diese Anleitung funktioniert zunächst auch nur mit der Server-IP (HTTPS dann erst,
|
||||||
|
sobald eine Domain feststeht).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System vorbereiten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip nginx git
|
||||||
|
```
|
||||||
|
|
||||||
|
Eigenen, nicht-root Nutzer für die App anlegen (falls noch nicht vorhanden):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser projekthub
|
||||||
|
sudo usermod -aG sudo projekthub # optional, falls dieser Nutzer auch administrieren soll
|
||||||
|
```
|
||||||
|
|
||||||
|
Ab hier alles als `projekthub`-Nutzer ausführen (`su - projekthub`), außer wo
|
||||||
|
explizit `sudo` für Systemdienste nötig ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code auf den Server bringen
|
||||||
|
|
||||||
|
Variante A — über Git (empfohlen, falls Repo z.B. auf einem privaten Git-Server
|
||||||
|
oder GitHub liegt):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
git clone <DEIN_REPO_URL> projekt-hub
|
||||||
|
cd projekt-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Variante B — Dateien direkt hochladen (z.B. von deinem Rechner aus):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -r projekt-hub/ projekthub@DEINE_SERVER_IP:~/projekt-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Python-Umgebung einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projekt-hub
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`gunicorn` und der Redis-Client sind bereits in `requirements.txt` enthalten —
|
||||||
|
kein separates Installieren nötig. Der **Redis-Server** (für Rate-Limiting bei
|
||||||
|
mehreren Workern, siehe Abschnitt 8a) ist ein Systemdienst und wird separat
|
||||||
|
installiert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y redis-server
|
||||||
|
sudo systemctl enable --now redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Konfigurationsdatei `.env` anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Werte für die Produktion:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=<langer-zufaelliger-string>
|
||||||
|
# Relativer sqlite-Name wird automatisch in den instance/-Ordner aufgelöst
|
||||||
|
# (-> instance/hub.sqlite). Keinen instance/-Pfad voranstellen.
|
||||||
|
DATABASE_URL=sqlite:///hub.sqlite
|
||||||
|
|
||||||
|
# Login
|
||||||
|
ALLOWED_EMAILS=deine@adresse.com
|
||||||
|
|
||||||
|
# Rate-Limiting über gemeinsames Backend (mehrere Worker), siehe Abschnitt 8a
|
||||||
|
RATELIMIT_STORAGE_URI=redis://localhost:6379
|
||||||
|
|
||||||
|
# Mail (siehe Abschnitt 7)
|
||||||
|
MAIL_SERVER=127.0.0.1
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=deine@adresse.com
|
||||||
|
MAIL_PASSWORD=<bridge-oder-relay-passwort>
|
||||||
|
MAIL_USE_TLS=true
|
||||||
|
|
||||||
|
# Domain (sobald bekannt, siehe Abschnitt 9)
|
||||||
|
SERVER_NAME=
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Einen zufälligen `SECRET_KEY` generieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` darf **niemals** ins Git-Repo committen — sicherstellen, dass `.gitignore`
|
||||||
|
`.env` enthält.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Datenbank initialisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p instance
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen, dass `instance/hub.sqlite` existiert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la instance/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testlauf mit dem Flask-Dev-Server (nur zur Kontrolle)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask run --host=0.0.0.0 --port=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Browser `http://DEINE_SERVER_IP:5000` aufrufen — die Login-Seite sollte
|
||||||
|
erscheinen. Danach mit `Strg+C` wieder stoppen; der Dev-Server ist **nicht** für
|
||||||
|
Dauerbetrieb gedacht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. E-Mail-Versand über Proton einrichten
|
||||||
|
|
||||||
|
### Option A: Proton Mail Bridge (Docker)
|
||||||
|
|
||||||
|
Die Bridge initialisiert beim Start lokale IMAP/SMTP-Server, über die die App
|
||||||
|
Mails verschicken kann. Auf dem VPS läuft sie containerisiert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y docker.io
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
|
||||||
|
docker volume create proton-bridge
|
||||||
|
docker run -d --name proton-bridge \
|
||||||
|
-v proton-bridge:/root \
|
||||||
|
-p 127.0.0.1:1025:25 \
|
||||||
|
-p 127.0.0.1:1143:143 \
|
||||||
|
spameier/proton-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach einmalig in den Container einloggen und die Bridge mit den Proton-Zugangsdaten
|
||||||
|
verbinden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it proton-bridge bridge --cli
|
||||||
|
# im Bridge-CLI: "login" eingeben und den Anweisungen folgen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Bridge zeigt danach lokale SMTP-Zugangsdaten (Benutzername + generiertes
|
||||||
|
Passwort) an — diese in die `.env` unter `MAIL_USERNAME` / `MAIL_PASSWORD`
|
||||||
|
eintragen, `MAIL_SERVER=127.0.0.1`, `MAIL_PORT=1025`.
|
||||||
|
|
||||||
|
**Bekannte Stolpersteine:** Proton Bridge nutzt ein selbstsigniertes Zertifikat und initiiert den TLS-Handshake selbst, was bei direkter Relay-Einbindung zu Problemen führen kann. Falls der Mailversand aus Flask nicht klappt, hilft es,
|
||||||
|
Postfix als lokalen Zwischen-Relay zu betreiben, der an die Bridge weiterleitet —
|
||||||
|
oder direkt auf Option B auszuweichen.
|
||||||
|
|
||||||
|
### Option B: EU-SMTP-Relay als Fallback
|
||||||
|
|
||||||
|
Falls Option A zu instabil ist: bei einem europäischen Transactional-Mail-Anbieter
|
||||||
|
(z.B. Infomaniak, Mailjet EU) ein kostenloses/günstiges Konto anlegen, SMTP-Zugangsdaten
|
||||||
|
erhalten und in `.env` eintragen — `MAIL_SERVER`, `MAIL_PORT`, `MAIL_USERNAME`,
|
||||||
|
`MAIL_PASSWORD`, `MAIL_USE_TLS` entsprechend anpassen. **Am Flask-Code ändert sich
|
||||||
|
nichts**, da die App generisch über `Flask-Mail`/SMTP konfiguriert ist.
|
||||||
|
|
||||||
|
### Testmail verschicken
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask shell
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
from app.extensions import mail
|
||||||
|
from flask_mail import Message
|
||||||
|
msg = Message("Testmail Projekt-Hub", recipients=["deine@adresse.com"])
|
||||||
|
msg.body = "Funktioniert!"
|
||||||
|
mail.send(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. gunicorn + systemd einrichten
|
||||||
|
|
||||||
|
Die Gunicorn-Konfiguration liegt versioniert im Repo: **`gunicorn.conf.py`**
|
||||||
|
(Worker-Anzahl, Socket, Logging). Startbefehl testen (im `.venv`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunicorn -c gunicorn.conf.py wsgi:app
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx an Gunicorn über einen Unix-Socket statt TCP-Port anzubinden reduziert den TCP-Overhead und ist die bevorzugte Variante, wenn beide auf demselben Host laufen. Mit `Strg+C` stoppen, danach den systemd-Service einrichten.
|
||||||
|
|
||||||
|
Die systemd-Unit liegt ebenfalls im Repo (**`deploy/projekt-hub.service`**) und
|
||||||
|
lädt die Gunicorn-Config per `-c`. Kopieren und ggf. Pfade/Nutzer anpassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service
|
||||||
|
sudo nano /etc/systemd/system/projekt-hub.service # Pfade/Nutzer prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Worker-Anzahl steuert `gunicorn.conf.py` (Default: (2 × CPU-Kerne) + 1,
|
||||||
|
per env `GUNICORN_WORKERS` überschreibbar).
|
||||||
|
|
||||||
|
Service aktivieren und starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable projekt-hub
|
||||||
|
sudo systemctl start projekt-hub
|
||||||
|
sudo systemctl status projekt-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8a. Redis für Rate-Limiting
|
||||||
|
|
||||||
|
Flask-Limiter (Login-Code-Anfragen) braucht bei **mehreren** gunicorn-Workern ein
|
||||||
|
gemeinsames Speicher-Backend — sonst zählt jeder Worker eigene Limits und das
|
||||||
|
Limit greift faktisch nicht. Redis wurde in Abschnitt 3 installiert; in der `.env`
|
||||||
|
verweist `RATELIMIT_STORAGE_URI` darauf:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
RATELIMIT_STORAGE_URI=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit Redis vor der App startet, in der systemd-Unit (auskommentierte Zeilen
|
||||||
|
aktivieren):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
After=network.target redis-server.service
|
||||||
|
Wants=redis-server.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Schnelltest, dass Redis läuft: `redis-cli ping` → `PONG`. Wer bewusst nur **einen**
|
||||||
|
Worker fährt, kann auch bei `memory://` bleiben.
|
||||||
|
|
||||||
|
Logs ansehen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u projekt-hub -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. nginx als Reverse Proxy
|
||||||
|
|
||||||
|
Die nginx-Vorlage liegt im Repo: **`deploy/nginx/projekt-hub.conf`** (Socket-Proxy
|
||||||
|
+ Static-Auslieferung). Kopieren und `server_name` (zunächst Server-IP) anpassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub
|
||||||
|
sudo nano /etc/nginx/sites-available/projekt-hub # server_name / Pfade prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktivieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Browser `http://DEINE_SERVER_IP` aufrufen — die Login-Seite sollte jetzt über
|
||||||
|
nginx ausgeliefert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Domain & HTTPS (sobald die Domain feststeht)
|
||||||
|
|
||||||
|
1. DNS A-Record der gewählten Domain/Subdomain auf die Server-IP zeigen lassen
|
||||||
|
2. In der nginx-Config `server_name` auf die Domain ändern, `nginx -t` +
|
||||||
|
`systemctl reload nginx`
|
||||||
|
3. Certbot installieren und Zertifikat holen:
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d deine-domain.de
|
||||||
|
```
|
||||||
|
Certbot passt die nginx-Config automatisch für HTTPS + Redirect von HTTP an.
|
||||||
|
4. In `.env`: `SERVER_NAME=deine-domain.de`, `SESSION_COOKIE_SECURE=true`,
|
||||||
|
`PREFERRED_URL_SCHEME=https` setzen, danach App neu starten:
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart projekt-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Updates einspielen
|
||||||
|
|
||||||
|
Wenn neue Features (z.B. weitere Module) hinzukommen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projekt-hub
|
||||||
|
git pull # oder neue Dateien per scp hochladen
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt # falls neue Abhängigkeiten
|
||||||
|
flask db upgrade # falls neue Migrationen
|
||||||
|
sudo systemctl restart projekt-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Backup
|
||||||
|
|
||||||
|
Die gesamte App-Datenbank ist eine einzelne SQLite-Datei. Einfaches Backup per
|
||||||
|
Cronjob (täglich, Beispiel):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeile hinzufügen:
|
||||||
|
|
||||||
|
```
|
||||||
|
0 3 * * * cp /home/projekthub/projekt-hub/instance/hub.sqlite /home/projekthub/backups/hub-$(date +\%F).sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
Vorher den Ordner anlegen: `mkdir -p ~/backups`. Da SQLite auch während Schreib-
|
||||||
|
zugriffen kopiert werden kann, ist für ein konsistentes Backup `sqlite3 hub.sqlite
|
||||||
|
".backup '/pfad/hub-$(date +%F).sqlite'"` die sauberere Variante.
|
||||||
|
|
||||||
|
> Hinweis: Der frühere JSON-Export wurde durch den **PDF-Export** ersetzt (eine
|
||||||
|
> druckbare, projektbasierte To-Do-Liste, kein Datensicherungs-Format). Die
|
||||||
|
> Datensicherung erfolgt also ausschließlich über die SQLite-Datei wie oben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Sicherheits-Checkliste (kurz)
|
||||||
|
|
||||||
|
- [ ] `.env` hat restriktive Dateirechte: `chmod 600 .env`
|
||||||
|
- [ ] `FLASK_ENV=production` gesetzt (kein Debug-Modus)
|
||||||
|
- [ ] Firewall (`ufw`) aktiv, nur Ports 22, 80, 443 offen:
|
||||||
|
```bash
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
- [ ] SSH-Login per Passwort deaktiviert, nur Key-Auth (falls noch nicht der Fall)
|
||||||
|
- [ ] Regelmäßige `apt upgrade` einplanen (z.B. `unattended-upgrades`)
|
||||||
0
README.md
Normal file
0
README.md
Normal file
166
TASKLISTE.md
Normal file
166
TASKLISTE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# TASKLISTE — Projekt-Hub Schritt für Schritt
|
||||||
|
|
||||||
|
Diese Liste begleitet dich von der Idee bis zur laufenden App auf deinem VPS.
|
||||||
|
Reihenfolge ist bewusst gewählt: erst lokal entwickeln und testen, dann deployen.
|
||||||
|
Häkchen kannst du selbst setzen, indem du `[ ]` zu `[x]` änderst.
|
||||||
|
|
||||||
|
Querverweis: technische Details zum Server-Setup stehen in `DEPLOYMENT.md`.
|
||||||
|
Architektur- und Designentscheidungen stehen in `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Vorbereitung (lokal, einmalig)
|
||||||
|
|
||||||
|
- [ ] Python 3.11+ auf deinem Rechner installiert prüfen (`python3 --version`)
|
||||||
|
- [ ] Projektordner anlegen, z.B. `projekt-hub/`
|
||||||
|
- [ ] Git-Repository initialisieren (`git init`) — auch wenn du es nur lokal/privat
|
||||||
|
nutzt, hilft Versionskontrolle enorm
|
||||||
|
- [ ] `CLAUDE.md` und `reference/projekte.html` in den Ordner legen (sind bereits
|
||||||
|
vorbereitet)
|
||||||
|
- [ ] `.gitignore` anlegen mit mindestens: `.env`, `instance/`, `__pycache__/`,
|
||||||
|
`*.pyc`, `.venv/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Projektgerüst mit Claude erstellen
|
||||||
|
|
||||||
|
- [ ] Mit Claude Code (oder Claude in diesem Chat) die `CLAUDE.md` als Kontext geben
|
||||||
|
und das Grundgerüst generieren lassen:
|
||||||
|
- App-Factory (`app/__init__.py`)
|
||||||
|
- Config-Klassen (`app/config.py`)
|
||||||
|
- Extensions (`app/extensions.py`: db, login_manager, mail)
|
||||||
|
- `requirements.txt`
|
||||||
|
- `.env.example`
|
||||||
|
- `wsgi.py` und `run.py`
|
||||||
|
- [ ] Virtuelle Umgebung anlegen und aktivieren:
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
- [ ] Abhängigkeiten installieren: `pip install -r requirements.txt`
|
||||||
|
- [ ] `.env` aus `.env.example` erstellen und lokale Platzhalterwerte eintragen
|
||||||
|
(echte Proton-Zugangsdaten erst später, siehe Phase 4)
|
||||||
|
- [ ] App startet lokal ohne Fehler: `flask run` bzw. `python run.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Datenmodell & Datenbank
|
||||||
|
|
||||||
|
- [ ] Models erstellen lassen: `User`, `LoginCode`, `Project`, `Task` (siehe
|
||||||
|
Datenmodell-Abschnitt in `CLAUDE.md`)
|
||||||
|
- [ ] Flask-Migrate einrichten (`flask db init`)
|
||||||
|
- [ ] Erste Migration erzeugen und anwenden:
|
||||||
|
```bash
|
||||||
|
flask db migrate -m "initial models"
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
- [ ] Prüfen, dass `instance/app.db` (oder ähnlich) erstellt wurde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Auth-Modul (E-Mail-Code-Login)
|
||||||
|
|
||||||
|
- [ ] Blueprint `auth` mit Routen `/login`, `/verify`, `/logout` erstellen lassen
|
||||||
|
- [ ] Templates `login.html` (E-Mail-Eingabe) und `verify.html` (Code-Eingabe) im
|
||||||
|
Dark-Theme-Stil des Prototyps
|
||||||
|
- [ ] Logik: 6-stelligen Code generieren, gehasht in `LoginCode` speichern,
|
||||||
|
`expires_at` = jetzt + 10 Minuten
|
||||||
|
- [ ] Logik: Code per Mail versenden (E-Mail-Versand erst mit Dummy/Konsole testen —
|
||||||
|
„Code wird in Terminal ausgegeben statt gemailt“, bis SMTP steht)
|
||||||
|
- [ ] Logik: Code-Verifizierung (korrekt? abgelaufen? schon benutzt?)
|
||||||
|
- [ ] Allowlist-Check: nur `ALLOWED_EMAILS` aus `.env` darf sich einloggen
|
||||||
|
- [ ] Flask-Login einrichten: `login_user`, `@login_required`, `current_user`
|
||||||
|
- [ ] Rate-Limiting für `/login` einrichten (Flask-Limiter), z.B. max. 5 Anfragen/Stunde
|
||||||
|
- [ ] Lokal testen: kompletter Login-Durchlauf mit Code aus Terminal-Ausgabe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — E-Mail-Versand mit Proton einrichten
|
||||||
|
|
||||||
|
- [ ] Proton-Mail-Plan prüfen: unterstützt Bridge (zahlender Plan erforderlich)
|
||||||
|
- [ ] **Option A (Proton Bridge)**: Bridge-Container/Setup gemäß `DEPLOYMENT.md`
|
||||||
|
einrichten, lokale SMTP-Zugangsdaten generieren
|
||||||
|
- [ ] **Option B (Fallback EU-Relay)**: falls Bridge auf dem Server Probleme macht,
|
||||||
|
alternativen EU-SMTP-Anbieter einrichten (z.B. Infomaniak) — Zugangsdaten in
|
||||||
|
`.env` eintragen, Code bleibt unverändert (nur SMTP-Settings ändern sich)
|
||||||
|
- [ ] `.env` mit echten SMTP-Zugangsdaten füllen (`MAIL_SERVER`, `MAIL_PORT`,
|
||||||
|
`MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_USE_TLS`)
|
||||||
|
- [ ] Testmail lokal verschicken (kleines Testskript oder über `/login`-Flow)
|
||||||
|
- [ ] Mail-Text prüfen: kurz, klar, kein Tracking, Code gut lesbar (z.B. groß,
|
||||||
|
monospace)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Basis-Layout & Theme
|
||||||
|
|
||||||
|
- [ ] `base.html` erstellen: Navigation (Platzhalter für künftige Module), Header,
|
||||||
|
Logout-Button
|
||||||
|
- [ ] `static/css/main.css` aus dem Farbschema von `reference/projekte.html`
|
||||||
|
übernehmen (CSS-Variablen: `--bg`, `--surface`, `--accent`, etc.)
|
||||||
|
- [ ] Login-/Verify-Templates an `base.html` anpassen
|
||||||
|
- [ ] Mobile-Ansicht prüfen (Viewport, Touch-Targets groß genug)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Modul „Projekte“ (Kernfunktion)
|
||||||
|
|
||||||
|
- [ ] Blueprint `projects` erstellen
|
||||||
|
- [ ] Routen: Übersicht, Projekt anlegen/löschen, Aufgabe anlegen/löschen/abhaken
|
||||||
|
- [ ] Template: Stats-Leiste (offen/erledigt/überfällig/Projekte)
|
||||||
|
- [ ] Template: Formular „Neue Aufgabe“ (Text, Projekt-Auswahl, Fälligkeitsdatum)
|
||||||
|
- [ ] Template: Projekt-Karten mit Aufgabenliste, Klapp-Funktion
|
||||||
|
- [ ] Template: „Alle Aufgaben“-Liste mit Filtern (alle/offen/überfällig/diese
|
||||||
|
Woche/erledigt)
|
||||||
|
- [ ] Fälligkeits-Badge-Logik aus dem Prototyp als Python-Helper/Jinja-Filter
|
||||||
|
übernehmen (überfällig/bald/ok/kein Datum)
|
||||||
|
- [ ] Alles `@login_required` und auf `current_user` beschränkt (jeder Nutzer sieht
|
||||||
|
nur eigene Daten)
|
||||||
|
- [ ] Manuell durchklicken: Projekt anlegen → Aufgaben anlegen → abhaken → filtern →
|
||||||
|
löschen — entspricht der Funktionalität des HTML-Prototyps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Backup-Export
|
||||||
|
|
||||||
|
- [ ] Route `/export` (oder „Einstellungen“-Bereich): erzeugt JSON-Datei mit allen
|
||||||
|
Projekten und Aufgaben des eingeloggten Users zum Download
|
||||||
|
- [ ] Kurzer Hinweistext in der UI: „Lade regelmäßig ein Backup herunter und speichere
|
||||||
|
es z.B. auf Proton Drive“
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Lokaler Gesamttest
|
||||||
|
|
||||||
|
- [ ] Kompletten Flow einmal komplett durchspielen: Login per Code → Projekte/Aufgaben
|
||||||
|
anlegen → Backup exportieren → Logout → erneut einloggen → Daten noch da?
|
||||||
|
- [ ] Mit zwei verschiedenen Browsern/Geräten im selben WLAN testen (Mobile-Ansicht!)
|
||||||
|
- [ ] `requirements.txt` final aktualisieren (`pip freeze > requirements.txt` —
|
||||||
|
danach manuell aufräumen, nur wirklich benötigte Pakete behalten)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Deployment auf den VPS
|
||||||
|
|
||||||
|
→ Details siehe `DEPLOYMENT.md`. Kurzfassung:
|
||||||
|
|
||||||
|
- [ ] Code auf den VPS bringen (Git-Push/Pull oder `scp`)
|
||||||
|
- [ ] Python-Umgebung auf dem Server einrichten
|
||||||
|
- [ ] `.env` auf dem Server mit echten Produktionswerten anlegen (NICHT ins
|
||||||
|
Repo committen)
|
||||||
|
- [ ] Datenbank-Migration auf dem Server ausführen
|
||||||
|
- [ ] gunicorn + systemd-Service einrichten
|
||||||
|
- [ ] nginx als Reverse Proxy konfigurieren
|
||||||
|
- [ ] HTTPS via Let's Encrypt einrichten (sobald Domain feststeht)
|
||||||
|
- [ ] Proton-Mail-Versand auf dem Server testen (Testmail)
|
||||||
|
- [ ] Kompletten Login-Flow auf der echten Domain/IP testen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Danach (spätere Erweiterungen, nicht jetzt)
|
||||||
|
|
||||||
|
- [ ] Domain final festlegen, Cookie-Settings (`SESSION_COOKIE_DOMAIN`) anpassen
|
||||||
|
- [ ] Weitere Mini-Apps als neue Blueprints ergänzen (Navigation erweitert sich
|
||||||
|
automatisch, falls Registry-Pattern aus `CLAUDE.md` umgesetzt wurde)
|
||||||
|
- [ ] Mehrere E-Mail-Adressen zur Allowlist hinzufügen, falls gewünscht
|
||||||
|
- [ ] Regelmäßige automatische Backups einrichten (z.B. Cronjob, der die
|
||||||
|
SQLite-Datei sichert)
|
||||||
85
app/__init__.py
Normal file
85
app/__init__.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""App-Factory für den Projekt-Hub.
|
||||||
|
|
||||||
|
Erzeugt die Flask-App, lädt die Config, bindet die Extensions und registriert
|
||||||
|
die Blueprints der einzelnen Mini-App-Module. Jedes neue Modul wird hier per
|
||||||
|
register_blueprint(...) eingebunden und über die zentrale Navigations-Registry
|
||||||
|
in der Navigation sichtbar gemacht — Kern bleibt unangetastet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, redirect, url_for
|
||||||
|
|
||||||
|
from .config import get_config
|
||||||
|
from .extensions import db, migrate, login_manager, mail, limiter, csrf
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zentrale Navigations-Registry ---------------------------------------
|
||||||
|
# Jedes Modul trägt hier einen Eintrag bei (Label, Endpoint, Icon). base.html
|
||||||
|
# rendert daraus die gemeinsame Navigation. Neues Modul = neuer Eintrag.
|
||||||
|
NAV_MODULES = [
|
||||||
|
{"label": "Projekte", "endpoint": "projects.index", "icon": "📁"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_name: str | None = None) -> Flask:
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
instance_relative_config=True,
|
||||||
|
instance_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "instance"),
|
||||||
|
)
|
||||||
|
app.config.from_object(get_config(config_name))
|
||||||
|
|
||||||
|
# instance/-Ordner sicherstellen (SQLite-Datei landet hier).
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Relativen sqlite-Pfad in den instance/-Ordner umbiegen.
|
||||||
|
uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||||||
|
if uri.startswith("sqlite:///") and not uri.startswith("sqlite:////"):
|
||||||
|
db_name = uri.replace("sqlite:///", "", 1)
|
||||||
|
abs_path = os.path.join(app.instance_path, db_name)
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{abs_path}"
|
||||||
|
|
||||||
|
_init_extensions(app)
|
||||||
|
_register_blueprints(app)
|
||||||
|
_register_context(app)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return redirect(url_for("projects.index"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _init_extensions(app: Flask) -> None:
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
mail.init_app(app)
|
||||||
|
limiter.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
# Modelle importieren, damit SQLAlchemy/Alembic sie kennen (Autogenerate).
|
||||||
|
from . import models # noqa: F401
|
||||||
|
from .projects import models as project_models # noqa: F401
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_blueprints(app: Flask) -> None:
|
||||||
|
from .auth import bp as auth_bp
|
||||||
|
from .projects import bp as projects_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(projects_bp)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_context(app: Flask) -> None:
|
||||||
|
@app.context_processor
|
||||||
|
def inject_nav():
|
||||||
|
# Navigations-Registry in allen Templates verfügbar machen.
|
||||||
|
return {"nav_modules": NAV_MODULES}
|
||||||
11
app/auth/__init__.py
Normal file
11
app/auth/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Auth-Modul (Blueprint): E-Mail-Code-Login.
|
||||||
|
|
||||||
|
Routen und Formulare folgen in Schritt 3. Hier wird vorerst nur der Blueprint
|
||||||
|
definiert, damit die App-Factory ihn registrieren kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
from . import routes # noqa: E402,F401
|
||||||
24
app/auth/forms.py
Normal file
24
app/auth/forms.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Auth-Formulare: E-Mail-Eingabe, Code-Eingabe (Flask-WTF, mit CSRF-Schutz)."""
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Email, Regexp
|
||||||
|
|
||||||
|
|
||||||
|
class EmailForm(FlaskForm):
|
||||||
|
email = StringField(
|
||||||
|
"E-Mail",
|
||||||
|
validators=[DataRequired(), Email(message="Bitte eine gültige E-Mail eingeben.")],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Code anfordern")
|
||||||
|
|
||||||
|
|
||||||
|
class CodeForm(FlaskForm):
|
||||||
|
code = StringField(
|
||||||
|
"Code",
|
||||||
|
validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Regexp(r"^\d{6}$", message="Bitte den 6-stelligen Code eingeben."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Einloggen")
|
||||||
105
app/auth/routes.py
Normal file
105
app/auth/routes.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Auth-Routen: /login (Code anfordern), /verify (Code prüfen), /logout."""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
render_template,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
flash,
|
||||||
|
session,
|
||||||
|
request,
|
||||||
|
current_app,
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
|
||||||
|
from ..extensions import limiter
|
||||||
|
from ..models import User
|
||||||
|
from . import bp
|
||||||
|
from .forms import EmailForm, CodeForm
|
||||||
|
from .service import (
|
||||||
|
email_allowed,
|
||||||
|
get_or_create_user,
|
||||||
|
issue_login_code,
|
||||||
|
send_login_code,
|
||||||
|
verify_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_key() -> str:
|
||||||
|
"""Rate-Limit pro E-Mail (Fallback: IP), damit Code-Spam begrenzt ist."""
|
||||||
|
return (request.form.get("email") or request.remote_addr or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_next(target: str | None) -> bool:
|
||||||
|
"""Open-Redirect verhindern: nur lokale, relative Pfade erlauben."""
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
parsed = urlparse(target)
|
||||||
|
return not parsed.netloc and not parsed.scheme and target.startswith("/")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit(
|
||||||
|
lambda: current_app.config["LOGIN_CODE_RATE_LIMIT"],
|
||||||
|
methods=["POST"],
|
||||||
|
key_func=_rate_key,
|
||||||
|
)
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("projects.index"))
|
||||||
|
|
||||||
|
form = EmailForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
email = form.email.data.lower().strip()
|
||||||
|
|
||||||
|
if not email_allowed(email):
|
||||||
|
flash("Diese E-Mail-Adresse ist nicht freigeschaltet.", "error")
|
||||||
|
return render_template("auth/login.html", form=form)
|
||||||
|
|
||||||
|
user = get_or_create_user(email)
|
||||||
|
code = issue_login_code(user)
|
||||||
|
try:
|
||||||
|
send_login_code(user, code)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
flash("Code konnte nicht gesendet werden. Bitte später erneut versuchen.", "error")
|
||||||
|
return render_template("auth/login.html", form=form)
|
||||||
|
|
||||||
|
session["auth_email"] = email
|
||||||
|
flash("Wir haben dir einen Code geschickt. Bitte gib ihn unten ein.", "info")
|
||||||
|
return redirect(url_for("auth.verify"))
|
||||||
|
|
||||||
|
return render_template("auth/login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/verify", methods=["GET", "POST"])
|
||||||
|
def verify():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("projects.index"))
|
||||||
|
|
||||||
|
email = session.get("auth_email")
|
||||||
|
if not email:
|
||||||
|
flash("Bitte zuerst deine E-Mail-Adresse eingeben.", "info")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
form = CodeForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
if user and verify_code(user, form.code.data.strip()):
|
||||||
|
login_user(user)
|
||||||
|
session.pop("auth_email", None)
|
||||||
|
flash("Eingeloggt. Willkommen zurück!", "success")
|
||||||
|
next_url = request.args.get("next")
|
||||||
|
return redirect(next_url if _is_safe_next(next_url) else url_for("projects.index"))
|
||||||
|
flash("Code ungültig oder abgelaufen. Bitte erneut versuchen.", "error")
|
||||||
|
|
||||||
|
return render_template("auth/verify.html", form=form, email=email)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
session.pop("auth_email", None)
|
||||||
|
flash("Du wurdest abgemeldet.", "info")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
95
app/auth/service.py
Normal file
95
app/auth/service.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Auth-Service-Logik: Allowlist, User-Anlage, Code-Erzeugung/-Versand/-Prüfung.
|
||||||
|
|
||||||
|
Trennt die fachliche Logik von den Routen. Codes werden 6-stellig numerisch
|
||||||
|
erzeugt, nur gehasht gespeichert und per E-Mail versandt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from ..extensions import db, mail
|
||||||
|
from ..models import User, LoginCode, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def email_allowed(email: str) -> bool:
|
||||||
|
"""True, wenn die E-Mail freigeschaltet ist.
|
||||||
|
|
||||||
|
Leere Allowlist (z.B. lokale Entwicklung ohne .env) = keine Einschränkung.
|
||||||
|
In Produktion sollte ALLOWED_EMAILS gesetzt sein (Self-Registration begrenzt).
|
||||||
|
"""
|
||||||
|
allow = current_app.config.get("ALLOWED_EMAILS", [])
|
||||||
|
return (not allow) or (email.lower() in allow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user(email: str) -> User:
|
||||||
|
"""User per E-Mail holen oder passwortlos anlegen (Self-Registration)."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
if user is None:
|
||||||
|
user = User(email=email)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def issue_login_code(user: User) -> str:
|
||||||
|
"""Neuen Login-Code erzeugen, alte unbenutzte Codes des Users verwerfen.
|
||||||
|
|
||||||
|
Gibt den Klartext-Code zurück (nur zum sofortigen Versand) — gespeichert
|
||||||
|
wird ausschließlich der Hash.
|
||||||
|
"""
|
||||||
|
LoginCode.query.filter_by(user_id=user.id, used=False).delete()
|
||||||
|
|
||||||
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10)
|
||||||
|
lc = LoginCode(user_id=user.id, expires_at=utcnow() + timedelta(minutes=ttl))
|
||||||
|
lc.set_code(code)
|
||||||
|
db.session.add(lc)
|
||||||
|
db.session.commit()
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def send_login_code(user: User, code: str) -> None:
|
||||||
|
"""Code per E-Mail senden (Klartext, kein HTML/Tracking).
|
||||||
|
|
||||||
|
Im Debug-Modus wird der Code zusätzlich geloggt und Versandfehler werden
|
||||||
|
verschluckt, damit lokal ohne SMTP getestet werden kann. In Produktion
|
||||||
|
wird eine Versand-Exception weitergereicht.
|
||||||
|
"""
|
||||||
|
ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10)
|
||||||
|
msg = Message(
|
||||||
|
subject="Dein Login-Code für den Projekt-Hub",
|
||||||
|
recipients=[user.email],
|
||||||
|
body=(
|
||||||
|
"Hallo,\n\n"
|
||||||
|
f"dein Login-Code lautet: {code}\n\n"
|
||||||
|
f"Der Code ist {ttl} Minuten gültig und nur einmal verwendbar.\n"
|
||||||
|
"Wenn du keinen Login angefordert hast, ignoriere diese Mail.\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
mail.send(msg)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
current_app.logger.warning("Mailversand fehlgeschlagen: %s", exc)
|
||||||
|
if not current_app.debug:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if current_app.debug:
|
||||||
|
current_app.logger.info("DEV — Login-Code für %s: %s", user.email, code)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(user: User, code: str) -> bool:
|
||||||
|
"""Neuesten unbenutzten Code des Users prüfen; bei Erfolg als used markieren."""
|
||||||
|
lc = (
|
||||||
|
LoginCode.query.filter_by(user_id=user.id, used=False)
|
||||||
|
.order_by(LoginCode.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if lc is None or not lc.is_valid(code):
|
||||||
|
return False
|
||||||
|
lc.used = True
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
82
app/config.py
Normal file
82
app/config.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Config-Klassen für die Projekt-Hub-App.
|
||||||
|
|
||||||
|
Liest Werte aus Umgebungsvariablen (.env wird in run.py / wsgi.py via
|
||||||
|
python-dotenv geladen). Keine Secrets im Code — alles über die Umgebung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: str | None, default: bool = False) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Basis-Config, gemeinsam für alle Umgebungen."""
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-change-me")
|
||||||
|
|
||||||
|
# --- Datenbank ---
|
||||||
|
# Default landet in der instance/-Datei (Pfad wird in der App-Factory gesetzt,
|
||||||
|
# falls hier ein relativer sqlite-Name steht).
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///hub.sqlite")
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# --- Host / URL ---
|
||||||
|
SERVER_NAME = os.environ.get("SERVER_NAME") or None
|
||||||
|
PREFERRED_URL_SCHEME = "https"
|
||||||
|
|
||||||
|
# --- Session-Cookie-Sicherheit ---
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
# In Produktion über HTTPS erzwingen; in Dev (HTTP) abschaltbar.
|
||||||
|
SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=True)
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
ALLOWED_EMAILS = [
|
||||||
|
e.strip().lower()
|
||||||
|
for e in os.environ.get("ALLOWED_EMAILS", "").split(",")
|
||||||
|
if e.strip()
|
||||||
|
]
|
||||||
|
LOGIN_CODE_TTL_MINUTES = int(os.environ.get("LOGIN_CODE_TTL_MINUTES", "10"))
|
||||||
|
LOGIN_CODE_RATE_LIMIT = os.environ.get("LOGIN_CODE_RATE_LIMIT", "5 per hour")
|
||||||
|
|
||||||
|
# --- Rate-Limiting (Flask-Limiter) ---
|
||||||
|
# Speicher-Backend. Default memory:// (pro Prozess, reicht für Dev/Einzelworker).
|
||||||
|
# In Produktion mit mehreren gunicorn-Workern: gemeinsames Backend nötig, sonst
|
||||||
|
# zählt jeder Worker eigene Limits. Empfehlung: redis://localhost:6379
|
||||||
|
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://")
|
||||||
|
# Restzeit-/Limit-Header in Antworten mitschicken.
|
||||||
|
RATELIMIT_HEADERS_ENABLED = True
|
||||||
|
|
||||||
|
# --- Mail ---
|
||||||
|
MAIL_SERVER = os.environ.get("MAIL_SERVER", "127.0.0.1")
|
||||||
|
MAIL_PORT = int(os.environ.get("MAIL_PORT", "1025"))
|
||||||
|
MAIL_USE_TLS = _as_bool(os.environ.get("MAIL_USE_TLS"))
|
||||||
|
MAIL_USE_SSL = _as_bool(os.environ.get("MAIL_USE_SSL"))
|
||||||
|
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or None
|
||||||
|
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or None
|
||||||
|
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER", "hub@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
config_by_name = {
|
||||||
|
"development": DevelopmentConfig,
|
||||||
|
"production": ProductionConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(name: str | None = None):
|
||||||
|
"""Wählt die Config-Klasse anhand von FLASK_ENV (Default: development)."""
|
||||||
|
name = name or os.environ.get("FLASK_ENV", "development")
|
||||||
|
return config_by_name.get(name, DevelopmentConfig)
|
||||||
26
app/extensions.py
Normal file
26
app/extensions.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Zentrale Instanziierung der Flask-Extensions.
|
||||||
|
|
||||||
|
Hier werden die Extension-Objekte ohne App erzeugt und in der App-Factory
|
||||||
|
(app/__init__.py) via init_app(app) gebunden. So vermeiden wir zirkuläre
|
||||||
|
Importe und können Module sauber auf gemeinsame Extensions zugreifen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_mail import Mail
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_wtf import CSRFProtect
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
mail = Mail()
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
|
# Auth-Konfiguration: Wohin umleiten, wenn Login nötig ist.
|
||||||
|
login_manager.login_view = "auth.login"
|
||||||
|
login_manager.login_message = "Bitte zuerst einloggen."
|
||||||
|
login_manager.login_message_category = "info"
|
||||||
84
app/models.py
Normal file
84
app/models.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Gemeinsame Modelle: User, LoginCode.
|
||||||
|
|
||||||
|
Modul-spezifische Modelle (Project, Task) liegen in app/projects/models.py.
|
||||||
|
Alle Zeitstempel werden in UTC gespeichert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Zeitzonenbewusster UTC-Zeitstempel (für DB-Defaults)."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_aware(dt: datetime) -> datetime:
|
||||||
|
"""SQLite gibt Datetimes naiv zurück — als UTC interpretieren."""
|
||||||
|
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
login_codes = db.relationship(
|
||||||
|
"LoginCode",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
projects = db.relationship(
|
||||||
|
"Project",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
tasks = db.relationship(
|
||||||
|
"Task",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginCode(db.Model):
|
||||||
|
"""Einmaliger, zeitlich begrenzter Login-Code (gehasht gespeichert)."""
|
||||||
|
|
||||||
|
__tablename__ = "login_codes"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
code_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
used = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User", back_populates="login_codes")
|
||||||
|
|
||||||
|
def set_code(self, code: str) -> None:
|
||||||
|
"""Klartext-Code hashen und ablegen (nie im Klartext speichern)."""
|
||||||
|
self.code_hash = generate_password_hash(code)
|
||||||
|
|
||||||
|
def check_code(self, code: str) -> bool:
|
||||||
|
return check_password_hash(self.code_hash, code)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return utcnow() > _as_aware(self.expires_at)
|
||||||
|
|
||||||
|
def is_valid(self, code: str) -> bool:
|
||||||
|
"""Code ist gültig: nicht benutzt, nicht abgelaufen, korrekt."""
|
||||||
|
return (not self.used) and (not self.is_expired) and self.check_code(code)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<LoginCode user_id={self.user_id} used={self.used}>"
|
||||||
17
app/projects/__init__.py
Normal file
17
app/projects/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Modul 1: Projekte & Aufgaben (Blueprint).
|
||||||
|
|
||||||
|
Routen, Modelle (Project, Task) und Templates folgen in Schritt 5. Templates
|
||||||
|
liegen unter app/projects/templates/projects/ und werden über den Blueprint
|
||||||
|
gefunden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint(
|
||||||
|
"projects",
|
||||||
|
__name__,
|
||||||
|
url_prefix="/projekte",
|
||||||
|
template_folder="templates",
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import routes # noqa: E402,F401
|
||||||
BIN
app/projects/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
app/projects/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
app/projects/fonts/DejaVuSans.ttf
Normal file
BIN
app/projects/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
70
app/projects/helpers.py
Normal file
70
app/projects/helpers.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Hilfsfunktionen des Projekte-Moduls.
|
||||||
|
|
||||||
|
Enthält die feste Farbpalette (zyklisch zugewiesen), die Fälligkeits-Badge-Logik
|
||||||
|
(portiert aus dem Prototyp `dueBadge`), die Aufgaben-Sortierung und ein
|
||||||
|
locale-unabhängiges deutsches Datumslabel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Feste Palette wie im Prototyp — neue Projekte bekommen zyklisch die nächste Farbe.
|
||||||
|
PROJECT_COLORS = [
|
||||||
|
"#e94560", "#f5a623", "#4ecca3", "#a78bfa", "#60a5fa",
|
||||||
|
"#f472b6", "#34d399", "#fb923c", "#818cf8", "#38bdf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
_WEEKDAYS_DE = [
|
||||||
|
"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag",
|
||||||
|
]
|
||||||
|
_MONTHS_DE = [
|
||||||
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def next_color(existing_count: int) -> str:
|
||||||
|
"""Nächste Farbe aus der Palette (zyklisch nach Anzahl vorhandener Projekte)."""
|
||||||
|
return PROJECT_COLORS[existing_count % len(PROJECT_COLORS)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(d: date) -> str:
|
||||||
|
"""Datum als dd.mm.yy (wie im Prototyp)."""
|
||||||
|
return f"{d.day:02d}.{d.month:02d}.{str(d.year)[2:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def due_badge(due: date | None, done: bool) -> dict:
|
||||||
|
"""Fälligkeits-Badge: CSS-Klasse + Text. Portiert aus dem Prototyp.
|
||||||
|
|
||||||
|
- kein Datum -> 'kein Datum' (neutral)
|
||||||
|
- erledigt -> Datum neutral
|
||||||
|
- überfällig -> rot, mit Tagen drüber
|
||||||
|
- <= 7 Tage -> orange, mit Resttagen
|
||||||
|
- sonst -> grün
|
||||||
|
"""
|
||||||
|
if due is None:
|
||||||
|
return {"cls": "due-none", "text": "kein Datum"}
|
||||||
|
|
||||||
|
label = _fmt(due)
|
||||||
|
if done:
|
||||||
|
return {"cls": "due-none", "text": label}
|
||||||
|
|
||||||
|
diff = (due - date.today()).days
|
||||||
|
if diff < 0:
|
||||||
|
return {"cls": "due-overdue", "text": f"⚠ {label} ({abs(diff)}d über)"}
|
||||||
|
if diff <= 7:
|
||||||
|
return {"cls": "due-soon", "text": f"⏳ {label} ({diff}d)"}
|
||||||
|
return {"cls": "due-ok", "text": f"📅 {label}"}
|
||||||
|
|
||||||
|
|
||||||
|
def sort_tasks(tasks: list) -> list:
|
||||||
|
"""Sortierung wie im Prototyp: offen vor erledigt, dann nach Datum (ohne zuletzt)."""
|
||||||
|
return sorted(
|
||||||
|
tasks,
|
||||||
|
key=lambda t: (t.done, t.due_date is None, t.due_date or date.max),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def today_label() -> str:
|
||||||
|
"""Deutsches Datumslabel ohne Locale-Abhängigkeit (z.B. 'Donnerstag, 11. Juni 2026')."""
|
||||||
|
d = date.today()
|
||||||
|
return f"{_WEEKDAYS_DE[d.weekday()]}, {d.day}. {_MONTHS_DE[d.month - 1]} {d.year}"
|
||||||
50
app/projects/models.py
Normal file
50
app/projects/models.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Modelle des Projekte-Moduls: Project, Task.
|
||||||
|
|
||||||
|
Aufgaben dürfen ohne Projekt existieren (project_id nullable). Beim Löschen
|
||||||
|
eines Projekts werden seine Aufgaben nicht gelöscht, sondern in der Routen-Logik
|
||||||
|
(Schritt 5) auf project_id=None gesetzt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from ..models import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class Project(db.Model):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
# Hex-Farbe aus fester Palette (z.B. "#5b8def").
|
||||||
|
color = db.Column(db.String(7), nullable=False, default="#5b8def")
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User", back_populates="projects")
|
||||||
|
tasks = db.relationship("Task", back_populates="project")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Project {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Task(db.Model):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
project_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
text = db.Column(db.String(500), nullable=False)
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
done = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User", back_populates="tasks")
|
||||||
|
project = db.relationship("Project", back_populates="tasks")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Task {self.text[:30]!r} done={self.done}>"
|
||||||
175
app/projects/pdf.py
Normal file
175
app/projects/pdf.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""PDF-Export: projektbasierte To-Do-Liste zum Ausdrucken.
|
||||||
|
|
||||||
|
Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten). Die DejaVu-
|
||||||
|
Schrift ist im Repo mitgeliefert (app/projects/fonts/), damit das Ergebnis auf
|
||||||
|
dem VPS unabhängig von dort installierten Fonts identisch aussieht und beliebiger
|
||||||
|
Aufgabentext (Umlaute, Sonderzeichen) korrekt gerendert wird.
|
||||||
|
|
||||||
|
Inhalt laut Festlegung: offene UND erledigte Aufgaben (erledigte abgehakt und
|
||||||
|
durchgestrichen), Aufgaben ohne Projekt in einem eigenen Abschnitt am Ende.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fpdf import FPDF
|
||||||
|
from fpdf.enums import XPos, YPos
|
||||||
|
|
||||||
|
from .helpers import sort_tasks, today_label
|
||||||
|
|
||||||
|
FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts")
|
||||||
|
|
||||||
|
# Farben (RGB) — bewusst auch in S/W-Druck lesbar (Info steht zusätzlich als Text).
|
||||||
|
COL_TEXT = (25, 25, 25)
|
||||||
|
COL_MUTED = (130, 130, 130)
|
||||||
|
COL_OVERDUE = (200, 50, 60)
|
||||||
|
COL_SOON = (190, 120, 20)
|
||||||
|
COL_RULE = (210, 210, 210)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(h: str) -> tuple:
|
||||||
|
h = h.lstrip("#")
|
||||||
|
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _due_text(due: date | None, done: bool):
|
||||||
|
"""Fälligkeit als reiner Text + Farbe (ohne Emoji, druckfreundlich)."""
|
||||||
|
if due is None:
|
||||||
|
return None, COL_MUTED
|
||||||
|
label = f"{due.day:02d}.{due.month:02d}.{str(due.year)[2:]}"
|
||||||
|
if done:
|
||||||
|
return label, COL_MUTED
|
||||||
|
diff = (due - date.today()).days
|
||||||
|
if diff < 0:
|
||||||
|
return f"{label} · {abs(diff)} Tag(e) überfällig", COL_OVERDUE
|
||||||
|
if diff == 0:
|
||||||
|
return f"{label} · heute fällig", COL_SOON
|
||||||
|
if diff <= 7:
|
||||||
|
return f"{label} · in {diff} Tag(en)", COL_SOON
|
||||||
|
return label, COL_MUTED
|
||||||
|
|
||||||
|
|
||||||
|
class TodoPDF(FPDF):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(format="A4")
|
||||||
|
self.set_auto_page_break(auto=True, margin=18)
|
||||||
|
self.set_margins(15, 15, 15)
|
||||||
|
self.add_font("DejaVu", "", os.path.join(FONT_DIR, "DejaVuSans.ttf"))
|
||||||
|
self.add_font("DejaVu", "B", os.path.join(FONT_DIR, "DejaVuSans-Bold.ttf"))
|
||||||
|
|
||||||
|
def header(self):
|
||||||
|
self.set_font("DejaVu", "B", 15)
|
||||||
|
self.set_text_color(*COL_TEXT)
|
||||||
|
self.cell(0, 9, "Projekte & Aufgaben", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
self.set_font("DejaVu", "", 9)
|
||||||
|
self.set_text_color(*COL_MUTED)
|
||||||
|
self.cell(0, 5, today_label(), new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
self.ln(3)
|
||||||
|
self.set_draw_color(*COL_RULE)
|
||||||
|
self.set_line_width(0.2)
|
||||||
|
y = self.get_y()
|
||||||
|
self.line(self.l_margin, y, self.w - self.r_margin, y)
|
||||||
|
self.ln(4)
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
self.set_y(-14)
|
||||||
|
self.set_font("DejaVu", "", 8)
|
||||||
|
self.set_text_color(*COL_MUTED)
|
||||||
|
self.cell(0, 5, f"Seite {self.page_no()}", align="C")
|
||||||
|
|
||||||
|
|
||||||
|
def _checkbox(pdf: TodoPDF, done: bool, size: float = 3.6) -> None:
|
||||||
|
"""Kästchen als Vektor zeichnen (scharf im Druck); bei done mit Haken."""
|
||||||
|
x, y = pdf.get_x(), pdf.get_y()
|
||||||
|
top = y + 1.1
|
||||||
|
pdf.set_draw_color(*COL_TEXT)
|
||||||
|
pdf.set_line_width(0.3)
|
||||||
|
pdf.rect(x, top, size, size)
|
||||||
|
if done:
|
||||||
|
pdf.line(x + 0.7, top + size * 0.55, x + size * 0.42, top + size - 0.6)
|
||||||
|
pdf.line(x + size * 0.42, top + size - 0.6, x + size - 0.4, top + 0.6)
|
||||||
|
pdf.set_x(x + size + 2.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_line(pdf: TodoPDF, task) -> None:
|
||||||
|
pdf.set_x(pdf.l_margin + 4)
|
||||||
|
_checkbox(pdf, task.done)
|
||||||
|
|
||||||
|
text_x = pdf.get_x()
|
||||||
|
avail = pdf.w - pdf.r_margin - text_x
|
||||||
|
|
||||||
|
pdf.set_font("DejaVu", "", 10.5)
|
||||||
|
pdf.set_text_color(*(COL_MUTED if task.done else COL_TEXT))
|
||||||
|
y_before = pdf.get_y()
|
||||||
|
pdf.multi_cell(avail, 5.2, task.text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
|
||||||
|
if task.done:
|
||||||
|
width = min(avail, pdf.get_string_width(task.text))
|
||||||
|
pdf.set_draw_color(*COL_MUTED)
|
||||||
|
pdf.set_line_width(0.3)
|
||||||
|
pdf.line(text_x, y_before + 2.6, text_x + width, y_before + 2.6)
|
||||||
|
|
||||||
|
due_str, col = _due_text(task.due_date, task.done)
|
||||||
|
if due_str:
|
||||||
|
pdf.set_x(text_x)
|
||||||
|
pdf.set_font("DejaVu", "", 8.5)
|
||||||
|
pdf.set_text_color(*col)
|
||||||
|
pdf.cell(0, 4.4, due_str, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
|
||||||
|
pdf.ln(1.4)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_header(pdf: TodoPDF, name: str, color_hex, open_count: int, total: int) -> None:
|
||||||
|
pdf.ln(2.5)
|
||||||
|
if color_hex:
|
||||||
|
x, y = pdf.get_x(), pdf.get_y()
|
||||||
|
pdf.set_fill_color(*_hex_to_rgb(color_hex))
|
||||||
|
pdf.ellipse(x, y + 1.6, 3, 3, style="F")
|
||||||
|
pdf.set_x(x + 5)
|
||||||
|
|
||||||
|
pdf.set_font("DejaVu", "B", 12)
|
||||||
|
pdf.set_text_color(*COL_TEXT)
|
||||||
|
pdf.cell(pdf.get_string_width(name) + 1, 6, name, new_x=XPos.RIGHT, new_y=YPos.TOP)
|
||||||
|
|
||||||
|
pdf.set_font("DejaVu", "", 9)
|
||||||
|
pdf.set_text_color(*COL_MUTED)
|
||||||
|
pdf.cell(0, 6, f" ({open_count} offen · {total} gesamt)",
|
||||||
|
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
pdf.ln(1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_todo_pdf(projects: list, tasks: list) -> bytes:
|
||||||
|
"""Baut die druckbare To-Do-PDF aus Projekten und Aufgaben des Users."""
|
||||||
|
by_pid = {}
|
||||||
|
for t in tasks:
|
||||||
|
by_pid.setdefault(t.project_id, []).append(t)
|
||||||
|
|
||||||
|
pdf = TodoPDF()
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
rendered = False
|
||||||
|
for p in projects:
|
||||||
|
ptasks = sort_tasks(by_pid.get(p.id, []))
|
||||||
|
if not ptasks:
|
||||||
|
continue
|
||||||
|
rendered = True
|
||||||
|
open_count = sum(1 for t in ptasks if not t.done)
|
||||||
|
_section_header(pdf, p.name, p.color, open_count, len(ptasks))
|
||||||
|
for t in ptasks:
|
||||||
|
_task_line(pdf, t)
|
||||||
|
|
||||||
|
no_project = sort_tasks(by_pid.get(None, []))
|
||||||
|
if no_project:
|
||||||
|
rendered = True
|
||||||
|
open_count = sum(1 for t in no_project if not t.done)
|
||||||
|
_section_header(pdf, "Ohne Projekt", None, open_count, len(no_project))
|
||||||
|
for t in no_project:
|
||||||
|
_task_line(pdf, t)
|
||||||
|
|
||||||
|
if not rendered:
|
||||||
|
pdf.set_font("DejaVu", "", 11)
|
||||||
|
pdf.set_text_color(*COL_MUTED)
|
||||||
|
pdf.cell(0, 8, "Keine Aufgaben vorhanden.",
|
||||||
|
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
|
||||||
|
|
||||||
|
return bytes(pdf.output())
|
||||||
206
app/projects/routes.py
Normal file
206
app/projects/routes.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Routen des Projekte-Moduls — voller Funktionsumfang aus dem HTML-Prototyp.
|
||||||
|
|
||||||
|
Alles ist login_required und strikt auf current_user gescoped. Schreibende
|
||||||
|
Aktionen laufen als POST + Redirect (PRG-Muster), damit kein Reload doppelt
|
||||||
|
ausführt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import render_template, request, redirect, url_for, flash, Response
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from .models import Project, Task
|
||||||
|
from .helpers import next_color, due_badge, sort_tasks, today_label
|
||||||
|
from .pdf import build_todo_pdf
|
||||||
|
from . import bp
|
||||||
|
|
||||||
|
VALID_FILTERS = {"all", "open", "overdue", "soon", "done"}
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_back():
|
||||||
|
"""Zur vorigen Ansicht zurück (erhält z.B. den Filter), sonst zur Übersicht."""
|
||||||
|
ref = request.referrer
|
||||||
|
if ref:
|
||||||
|
parsed = urlparse(ref)
|
||||||
|
if not parsed.netloc or parsed.netloc == urlparse(request.host_url).netloc:
|
||||||
|
return redirect(ref)
|
||||||
|
return redirect(url_for("projects.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_project_or_none(project_id):
|
||||||
|
"""Projekt des aktuellen Users per (string) ID holen — oder None."""
|
||||||
|
try:
|
||||||
|
pid = int(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return Project.query.filter_by(id=pid, user_id=current_user.id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
flt = request.args.get("filter", "all")
|
||||||
|
if flt not in VALID_FILTERS:
|
||||||
|
flt = "all"
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
week = today + timedelta(days=7)
|
||||||
|
|
||||||
|
projects = (
|
||||||
|
Project.query.filter_by(user_id=current_user.id)
|
||||||
|
.order_by(Project.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tasks = Task.query.filter_by(user_id=current_user.id).all()
|
||||||
|
projects_by_id = {p.id: p for p in projects}
|
||||||
|
|
||||||
|
# Statistik-Leiste
|
||||||
|
stats = {
|
||||||
|
"open": sum(1 for t in tasks if not t.done),
|
||||||
|
"done": sum(1 for t in tasks if t.done),
|
||||||
|
"overdue": sum(1 for t in tasks if not t.done and t.due_date and t.due_date < today),
|
||||||
|
"projects": len(projects),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ansicht "Nach Projekt"
|
||||||
|
project_views = []
|
||||||
|
for p in projects:
|
||||||
|
ptasks = sort_tasks([t for t in tasks if t.project_id == p.id])
|
||||||
|
project_views.append(
|
||||||
|
{
|
||||||
|
"project": p,
|
||||||
|
"tasks": ptasks,
|
||||||
|
"open": sum(1 for t in ptasks if not t.done),
|
||||||
|
"total": len(ptasks),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
proj_open_total = sum(v["open"] for v in project_views)
|
||||||
|
|
||||||
|
# Ansicht "Alle Aufgaben" (gefiltert)
|
||||||
|
def keep(t):
|
||||||
|
if flt == "open":
|
||||||
|
return not t.done
|
||||||
|
if flt == "done":
|
||||||
|
return t.done
|
||||||
|
if flt == "overdue":
|
||||||
|
return not t.done and t.due_date and t.due_date < today
|
||||||
|
if flt == "soon":
|
||||||
|
return not t.done and t.due_date and today <= t.due_date <= week
|
||||||
|
return True
|
||||||
|
|
||||||
|
all_tasks = sort_tasks([t for t in tasks if keep(t)])
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"projects/index.html",
|
||||||
|
projects=projects,
|
||||||
|
projects_by_id=projects_by_id,
|
||||||
|
project_views=project_views,
|
||||||
|
proj_open_total=proj_open_total,
|
||||||
|
all_tasks=all_tasks,
|
||||||
|
stats=stats,
|
||||||
|
active_filter=flt,
|
||||||
|
today_label=today_label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projekte ─────────────────────────────────────────────────────────────
|
||||||
|
@bp.route("/projects", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_project():
|
||||||
|
name = (request.form.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
flash("Projektname fehlt.", "error")
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
count = Project.query.filter_by(user_id=current_user.id).count()
|
||||||
|
project = Project(user_id=current_user.id, name=name[:120], color=next_color(count))
|
||||||
|
db.session.add(project)
|
||||||
|
db.session.commit()
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects/<int:project_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_project(project_id):
|
||||||
|
project = Project.query.filter_by(id=project_id, user_id=current_user.id).first_or_404()
|
||||||
|
# Aufgaben bleiben erhalten, verlieren nur die Projektzuordnung.
|
||||||
|
for t in project.tasks:
|
||||||
|
t.project_id = None
|
||||||
|
db.session.delete(project)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Projekt gelöscht. Aufgaben bleiben erhalten.", "info")
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aufgaben ─────────────────────────────────────────────────────────────
|
||||||
|
@bp.route("/tasks", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_task():
|
||||||
|
text = (request.form.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
flash("Aufgabentext fehlt.", "error")
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
project = _owned_project_or_none(request.form.get("project_id"))
|
||||||
|
due_raw = (request.form.get("due_date") or "").strip()
|
||||||
|
due = None
|
||||||
|
if due_raw:
|
||||||
|
try:
|
||||||
|
due = datetime.strptime(due_raw, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
due = None
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
user_id=current_user.id,
|
||||||
|
text=text[:500],
|
||||||
|
project_id=project.id if project else None,
|
||||||
|
due_date=due,
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks/<int:task_id>/toggle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def toggle_task(task_id):
|
||||||
|
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
|
||||||
|
task.done = not task.done
|
||||||
|
db.session.commit()
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_task(task_id):
|
||||||
|
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
|
||||||
|
db.session.delete(task)
|
||||||
|
db.session.commit()
|
||||||
|
return _redirect_back()
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF-Export (druckbare To-Do-Liste) ──────────────────────────────────
|
||||||
|
@bp.route("/export.pdf")
|
||||||
|
@login_required
|
||||||
|
def export_pdf():
|
||||||
|
projects = (
|
||||||
|
Project.query.filter_by(user_id=current_user.id)
|
||||||
|
.order_by(Project.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tasks = Task.query.filter_by(user_id=current_user.id).all()
|
||||||
|
pdf_bytes = build_todo_pdf(projects, tasks)
|
||||||
|
|
||||||
|
filename = f"aufgaben_{date.today().isoformat()}.pdf"
|
||||||
|
return Response(
|
||||||
|
pdf_bytes,
|
||||||
|
mimetype="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"inline; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Fälligkeits-Badge als Template-Global verfügbar machen.
|
||||||
|
bp.add_app_template_global(due_badge, "due_badge")
|
||||||
0
app/projects/templates/projects/.gitkeep
Normal file
0
app/projects/templates/projects/.gitkeep
Normal file
21
app/projects/templates/projects/_macros.html
Normal file
21
app/projects/templates/projects/_macros.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{# Wiederverwendbare Aufgabenzeile — genutzt in Projektkarten und der "Alle Aufgaben"-Liste.
|
||||||
|
show_project: ob das Projekt-Tag angezeigt wird (in der Projektkarte unnötig). #}
|
||||||
|
{% macro task_row(task, show_project, projects_by_id) %}
|
||||||
|
{% set badge = due_badge(task.due_date, task.done) %}
|
||||||
|
<div class="task{{ ' done' if task.done }}">
|
||||||
|
<form method="post" action="{{ url_for('projects.toggle_task', task_id=task.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="checkbox" {{ 'checked' if task.done }} onchange="this.form.submit()"
|
||||||
|
title="Erledigt umschalten">
|
||||||
|
</form>
|
||||||
|
<div class="task-body">
|
||||||
|
<span class="task-text">{{ task.text }}</span>{% if show_project and task.project_id and projects_by_id.get(task.project_id) %}{% set p = projects_by_id[task.project_id] %}<span class="task-project-tag" style="background:{{ p.color }}22;color:{{ p.color }}">{{ p.name }}</span>{% endif %}
|
||||||
|
<div><span class="task-due {{ badge.cls }}">{{ badge.text }}</span></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('projects.delete_task', task_id=task.id) }}"
|
||||||
|
data-confirm="Aufgabe löschen?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="task-delete" type="submit" title="Löschen">✕</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
121
app/projects/templates/projects/index.html
Normal file
121
app/projects/templates/projects/index.html
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "projects/_macros.html" import task_row %}
|
||||||
|
{% block title %}Projekte & Aufgaben · Projekt-Hub{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Projekte <span>&</span> Aufgaben</h1>
|
||||||
|
<div class="today">{{ today_label }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Statistik-Leiste ── #}
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><strong>{{ stats.open }}</strong>Offen</div>
|
||||||
|
<div class="stat"><strong>{{ stats.done }}</strong>Erledigt</div>
|
||||||
|
<div class="stat"><strong style="color:var(--overdue)">{{ stats.overdue }}</strong>Überfällig</div>
|
||||||
|
<div class="stat"><strong>{{ stats.projects }}</strong>Projekte</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Druckbare PDF-Liste ── #}
|
||||||
|
<div class="sync-bar">
|
||||||
|
<span class="sync-label">Drucken</span>
|
||||||
|
<a class="btn-sync export" href="{{ url_for('projects.export_pdf') }}" target="_blank"
|
||||||
|
rel="noopener">PDF / Druckliste</a>
|
||||||
|
<span class="sync-hint">Projektbasierte To-Do-Liste als PDF zum Ausdrucken</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Aufgabe hinzufügen ── #}
|
||||||
|
<form class="add-form" method="post" action="{{ url_for('projects.create_task') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" name="text" placeholder="Neue Aufgabe …" autocomplete="off"
|
||||||
|
autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<select name="project_id">
|
||||||
|
<option value="">— kein Projekt —</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="date" name="due_date">
|
||||||
|
<button class="btn-add" type="submit">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Projekt anlegen ── #}
|
||||||
|
<form class="project-mgmt" method="post" action="{{ url_for('projects.create_project') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<span style="font-size:.78rem;color:var(--muted)">Projekt:</span>
|
||||||
|
<input type="text" name="name" placeholder="Name …" autocomplete="off" required>
|
||||||
|
<button class="btn-small" type="submit">+ Projekt anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Ansicht: Nach Projekt ── #}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header clickable js-collapse" data-target="projects-body">
|
||||||
|
<span class="section-title">Nach Projekt</span>
|
||||||
|
<span class="section-count">{{ proj_open_total }} offen</span>
|
||||||
|
<button class="collapse-btn" type="button">▲</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="projects-body">
|
||||||
|
{% for view in project_views %}
|
||||||
|
{% set p = view.project %}
|
||||||
|
<div class="project-card">
|
||||||
|
<div class="project-card-header js-collapse" data-target="proj-body-{{ p.id }}">
|
||||||
|
<span class="project-dot" style="background:{{ p.color }}"></span>
|
||||||
|
<span class="project-name">{{ p.name }}</span>
|
||||||
|
<span class="project-meta">{{ view.open }} offen · {{ view.total }} gesamt</span>
|
||||||
|
<form method="post" action="{{ url_for('projects.delete_project', project_id=p.id) }}"
|
||||||
|
data-confirm="Projekt löschen? Aufgaben bleiben erhalten."
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="project-delete" type="submit" title="Projekt löschen">✕</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="task-list" id="proj-body-{{ p.id }}">
|
||||||
|
{% for t in view.tasks %}
|
||||||
|
{{ task_row(t, false, projects_by_id) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-hint">Keine Aufgaben — oben hinzufügen.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-hint">Noch keine Projekte — oben anlegen.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
{# ── Ansicht: Alle Aufgaben (gefiltert) ── #}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Alle Aufgaben</span>
|
||||||
|
<span class="section-count">{{ all_tasks|length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<span class="filter-label">Zeigen:</span>
|
||||||
|
{% set filters = [('all', 'Alle'), ('open', 'Offen'), ('overdue', 'Überfällig'),
|
||||||
|
('soon', 'Diese Woche'), ('done', 'Erledigt')] %}
|
||||||
|
{% for key, label in filters %}
|
||||||
|
<a class="filter-btn {{ 'active' if active_filter == key }}"
|
||||||
|
href="{{ url_for('projects.index', filter=key) }}#alle">{{ label }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-card" id="alle">
|
||||||
|
<div class="task-list all-tasks-list">
|
||||||
|
{% for t in all_tasks %}
|
||||||
|
{{ task_row(t, true, projects_by_id) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-hint">Keine Aufgaben in dieser Ansicht.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
485
app/static/css/main.css
Normal file
485
app/static/css/main.css
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
/* =========================================================================
|
||||||
|
Projekt-Hub — Dark-Theme-Basis
|
||||||
|
Palette & Komponenten aus dem Prototyp (reference/projekte.html) übernommen
|
||||||
|
und als gemeinsames Theme für den ganzen Hub aufbereitet. Module nutzen
|
||||||
|
diese Variablen und Komponentenklassen.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--surface: #16213e;
|
||||||
|
--card: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent2: #f5a623;
|
||||||
|
--text: #eaeaea;
|
||||||
|
--muted: #8892a4;
|
||||||
|
--done: #3a4a5c;
|
||||||
|
--done-text: #566272;
|
||||||
|
--overdue: #ff6b6b;
|
||||||
|
--soon: #f5a623;
|
||||||
|
--ok: #4ecca3;
|
||||||
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
|
--font: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Hub-Navigation (gemeinsame Leiste über allen Modulen)
|
||||||
|
========================================================================= */
|
||||||
|
.hub-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.hub-brand span { color: var(--accent); }
|
||||||
|
|
||||||
|
.hub-nav-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.hub-nav-list a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.hub-nav-list a:hover { color: var(--text); background: var(--card); }
|
||||||
|
|
||||||
|
.hub-nav-auth { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.hub-user { color: var(--muted); font-size: 0.82rem; }
|
||||||
|
.hub-nav-auth a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.hub-nav-auth a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.hub-main {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Lösch-Formulare in Aufgaben/Projekten sollen das Flex-Layout nicht stören. */
|
||||||
|
.task form, .project-card-header form { display: inline-flex; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Flash-Meldungen
|
||||||
|
========================================================================= */
|
||||||
|
.flashes { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.flash {
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.flash-info { background: var(--surface); color: var(--text); }
|
||||||
|
.flash-success { background: rgba(78, 204, 163, 0.12); border-color: var(--ok); color: var(--ok); }
|
||||||
|
.flash-error { background: rgba(255, 107, 107, 0.14); border-color: var(--overdue); color: var(--overdue); }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Auth-Karten / Formulare
|
||||||
|
========================================================================= */
|
||||||
|
.auth-card {
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
.auth-card h1 { margin-top: 0; margin-bottom: 0.4rem; font-size: 1.4rem; }
|
||||||
|
.auth-card .muted { margin-bottom: 1.2rem; font-size: 0.88rem; line-height: 1.5; }
|
||||||
|
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.stack label { font-size: 0.85rem; color: var(--muted); }
|
||||||
|
.stack input[type="text"],
|
||||||
|
.stack input[type="email"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.stack input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.field-error { color: var(--overdue); font-size: 0.83rem; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Modul "Projekte" — Komponenten (aus dem Prototyp)
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* ── Seitenkopf ── */
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.page-head h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.page-head h1 span { color: var(--accent); }
|
||||||
|
.today { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Stats-Leiste ── */
|
||||||
|
.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
||||||
|
.stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.stat strong { color: var(--text); font-size: 1.1rem; display: block; }
|
||||||
|
|
||||||
|
/* ── Aufgaben-Formular ── */
|
||||||
|
.add-form {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.add-form .row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
||||||
|
.add-form input[type="text"],
|
||||||
|
.add-form input[type="date"],
|
||||||
|
.add-form select {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.add-form input[type="text"] { flex: 1; min-width: 180px; }
|
||||||
|
.add-form input[type="date"] { width: 145px; color-scheme: dark; }
|
||||||
|
.add-form select { width: 150px; }
|
||||||
|
.add-form input:focus, .add-form select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-add:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Projektverwaltung ── */
|
||||||
|
.project-mgmt {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.project-mgmt input[type="text"] {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.project-mgmt input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-small:hover { background: var(--card); }
|
||||||
|
|
||||||
|
/* ── Abschnitte ── */
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.section-header.clickable { cursor: pointer; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.section-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
}
|
||||||
|
.collapse-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Projektkarte ── */
|
||||||
|
.project-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.project-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.project-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.project-name { font-weight: 600; font-size: 0.95rem; flex: 1; }
|
||||||
|
.project-meta { font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.project-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.project-delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Aufgaben-Eintrag ── */
|
||||||
|
.task-list { padding: 0.4rem 0; }
|
||||||
|
.task {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.task:last-child { border-bottom: none; }
|
||||||
|
.task:hover { background: rgba(255, 255, 255, 0.03); }
|
||||||
|
.task.done { opacity: 0.5; }
|
||||||
|
.task input[type="checkbox"] {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-body { flex: 1; min-width: 0; }
|
||||||
|
.task-text { font-size: 0.9rem; line-height: 1.4; word-break: break-word; }
|
||||||
|
.task.done .task-text { text-decoration: line-through; color: var(--done-text); }
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.due-overdue { background: rgba(255, 107, 107, 0.18); color: var(--overdue); }
|
||||||
|
.due-soon { background: rgba(245, 166, 35, 0.18); color: var(--soon); }
|
||||||
|
.due-ok { background: rgba(78, 204, 163, 0.12); color: var(--ok); }
|
||||||
|
.due-none { background: var(--card); color: var(--muted); }
|
||||||
|
|
||||||
|
.task-project-tag {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task:hover .task-delete { opacity: 1; }
|
||||||
|
.task-delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter-Leiste ── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.filter-btn:hover:not(.active) { border-color: var(--muted); color: var(--text); }
|
||||||
|
.filter-label { font-size: 0.75rem; color: var(--muted); margin-right: 0.2rem; }
|
||||||
|
|
||||||
|
/* ── Trenner ── */
|
||||||
|
.divider { margin-bottom: 2rem; border: none; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── Backup/Sync-Leiste ── */
|
||||||
|
.sync-bar {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.sync-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.btn-sync {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-sync:hover { background: #1a3a5c; border-color: var(--accent); }
|
||||||
|
.btn-sync.export { border-color: var(--ok); color: var(--ok); }
|
||||||
|
.btn-sync.export:hover { background: rgba(78, 204, 163, 0.1); }
|
||||||
|
.btn-sync.import { border-color: var(--accent2); color: var(--accent2); }
|
||||||
|
.btn-sync.import:hover { background: rgba(245, 166, 35, 0.1); }
|
||||||
|
.sync-hint { font-size: 0.72rem; color: var(--muted); margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Toast ── */
|
||||||
|
.sync-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(80px);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 100;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sync-toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
26
app/static/js/app.js
Normal file
26
app/static/js/app.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Minimal-JS für den Projekt-Hub — Progressive Enhancement, keine Abhängigkeiten.
|
||||||
|
|
||||||
|
// 1) Bestätigungsdialog für Formulare mit data-confirm (z.B. Löschen).
|
||||||
|
document.addEventListener("submit", function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (form.dataset && form.dataset.confirm) {
|
||||||
|
if (!window.confirm(form.dataset.confirm)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Klappbare Bereiche: Klick auf .js-collapse blendet das Ziel (data-target) aus.
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var head = e.target.closest(".js-collapse");
|
||||||
|
if (!head) return;
|
||||||
|
// Klicks auf interaktive Elemente (z.B. Lösch-Button) nicht als Klapp-Klick werten.
|
||||||
|
if (e.target.closest("button, a, input, form") && !e.target.closest(".collapse-btn")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var target = document.getElementById(head.dataset.target);
|
||||||
|
if (!target) return;
|
||||||
|
var collapsed = target.classList.toggle("hidden");
|
||||||
|
var btn = head.querySelector(".collapse-btn");
|
||||||
|
if (btn) btn.textContent = collapsed ? "▼" : "▲";
|
||||||
|
});
|
||||||
20
app/templates/auth/login.html
Normal file
20
app/templates/auth/login.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Anmelden · Projekt-Hub{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
<p class="muted">Gib deine E-Mail-Adresse ein — wir schicken dir einen
|
||||||
|
6-stelligen Code zum Einloggen.</p>
|
||||||
|
|
||||||
|
<form method="post" class="stack" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<label for="email">{{ form.email.label.text }}</label>
|
||||||
|
{{ form.email(id="email", type="email", autocomplete="email", autofocus=true,
|
||||||
|
placeholder="du@beispiel.de") }}
|
||||||
|
{% for err in form.email.errors %}
|
||||||
|
<span class="field-error">{{ err }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{{ form.submit(class="btn") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
app/templates/auth/verify.html
Normal file
22
app/templates/auth/verify.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Code eingeben · Projekt-Hub{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Code eingeben</h1>
|
||||||
|
<p class="muted">Wir haben einen Code an <strong>{{ email }}</strong> geschickt.
|
||||||
|
Er ist nur kurz gültig.</p>
|
||||||
|
|
||||||
|
<form method="post" class="stack" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<label for="code">{{ form.code.label.text }}</label>
|
||||||
|
{{ form.code(id="code", inputmode="numeric", autocomplete="one-time-code",
|
||||||
|
autofocus=true, maxlength="6", placeholder="123456") }}
|
||||||
|
{% for err in form.code.errors %}
|
||||||
|
<span class="field-error">{{ err }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{{ form.submit(class="btn") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted"><a href="{{ url_for('auth.login') }}">Andere E-Mail verwenden</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
41
app/templates/base.html
Normal file
41
app/templates/base.html
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Projekt-Hub{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="hub-nav">
|
||||||
|
<a class="hub-brand" href="{{ url_for('index') }}">Projekt<span>Hub</span>
|
||||||
|
<ul class="hub-nav-list">
|
||||||
|
{% for mod in nav_modules %}
|
||||||
|
<li><a href="{{ url_for(mod.endpoint) }}">{{ mod.icon }} {{ mod.label }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="hub-nav-auth">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<span class="hub-user">{{ current_user.email }}</span>
|
||||||
|
<a href="{{ url_for('auth.logout') }}">Abmelden</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="hub-main">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flashes">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
deploy/README.md
Normal file
32
deploy/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# deploy/ — Deployment-Artefakte
|
||||||
|
|
||||||
|
Versionierte Vorlagen für den VPS-Betrieb. Die ausführliche Schritt-für-Schritt-
|
||||||
|
Anleitung steht in [`../DEPLOYMENT.md`](../DEPLOYMENT.md); hier liegen die Dateien,
|
||||||
|
die dort referenziert werden.
|
||||||
|
|
||||||
|
| Datei | Zweck | Ziel auf dem VPS |
|
||||||
|
|-------|-------|------------------|
|
||||||
|
| `../gunicorn.conf.py` | Gunicorn-Konfiguration (Worker, Socket, Logging) | bleibt im Projektordner, via `-c` geladen |
|
||||||
|
| `projekt-hub.service` | systemd-Unit (Autostart, Neustart bei Absturz) | `/etc/systemd/system/projekt-hub.service` |
|
||||||
|
| `nginx/projekt-hub.conf` | nginx-Reverse-Proxy (Socket-Proxy + Static) | `/etc/nginx/sites-available/projekt-hub` |
|
||||||
|
|
||||||
|
## Vor dem Kopieren anpassen
|
||||||
|
|
||||||
|
Alle Dateien gehen von diesen Annahmen aus — bei Abweichung anpassen:
|
||||||
|
|
||||||
|
- Nutzer: `projekthub`, Gruppe `www-data`
|
||||||
|
- Projektpfad: `/home/projekthub/projekt-hub`
|
||||||
|
- venv: `.venv` im Projektordner
|
||||||
|
- Socket: `projekt-hub.sock` im Projektordner
|
||||||
|
|
||||||
|
## Rate-Limiting-Backend
|
||||||
|
|
||||||
|
Mit mehreren gunicorn-Workern braucht Flask-Limiter ein gemeinsames Backend,
|
||||||
|
sonst zählt jeder Worker eigene Limits. In der `.env`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
RATELIMIT_STORAGE_URI=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis installieren: `sudo apt install -y redis-server` (siehe `DEPLOYMENT.md`).
|
||||||
|
Ohne Redis bleibt `memory://` möglich — dann am besten nur **ein** Worker.
|
||||||
36
deploy/nginx/projekt-hub.conf
Normal file
36
deploy/nginx/projekt-hub.conf
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# nginx-Reverse-Proxy für den Projekt-Hub.
|
||||||
|
#
|
||||||
|
# Installation:
|
||||||
|
# sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/
|
||||||
|
# sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
#
|
||||||
|
# HTTPS wird hier NICHT manuell konfiguriert — certbot --nginx ergänzt die
|
||||||
|
# TLS-Direktiven automatisch, sobald eine Domain feststeht (siehe DEPLOYMENT.md §10).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
# Zunächst Server-IP; später auf die echte Domain ändern und certbot laufen lassen.
|
||||||
|
server_name DEINE_SERVER_IP;
|
||||||
|
|
||||||
|
# Statische Dateien direkt von nginx ausliefern (schneller, entlastet gunicorn).
|
||||||
|
location /static/ {
|
||||||
|
alias /home/projekthub/projekt-hub/app/static/;
|
||||||
|
expires 30d;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/home/projekthub/projekt-hub/projekt-hub.sock;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Wichtig: App nimmt HTTPS an (PREFERRED_URL_SCHEME=https, Secure-Cookies).
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PDF-Export und sonstige Antworten dürfen ruhig etwas größer sein.
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
34
deploy/projekt-hub.service
Normal file
34
deploy/projekt-hub.service
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# systemd-Service für den Projekt-Hub (Gunicorn).
|
||||||
|
#
|
||||||
|
# Installation:
|
||||||
|
# sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service
|
||||||
|
# # Pfade/Nutzer unten ggf. anpassen, dann:
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now projekt-hub
|
||||||
|
# sudo systemctl status projekt-hub
|
||||||
|
#
|
||||||
|
# Annahme: Code unter /home/projekthub/projekt-hub, venv unter .venv.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Projekt-Hub Flask App (Gunicorn)
|
||||||
|
After=network.target
|
||||||
|
# Falls Rate-Limiting über lokales Redis läuft, Redis vorher starten:
|
||||||
|
# After=network.target redis-server.service
|
||||||
|
# Wants=redis-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=projekthub
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/home/projekthub/projekt-hub
|
||||||
|
Environment="PATH=/home/projekthub/projekt-hub/.venv/bin"
|
||||||
|
ExecStart=/home/projekthub/projekt-hub/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# Etwas Härtung (optional, aber empfohlen):
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
31
gunicorn.conf.py
Normal file
31
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Gunicorn-Konfiguration für den Projekt-Hub.
|
||||||
|
|
||||||
|
Aufruf (im aktivierten venv, WorkingDirectory = Projektordner):
|
||||||
|
gunicorn -c gunicorn.conf.py wsgi:app
|
||||||
|
|
||||||
|
Wird vom systemd-Service (deploy/projekt-hub.service) genutzt. Werte sind über
|
||||||
|
Umgebungsvariablen überschreibbar, sinnvolle Defaults für einen kleinen VPS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Bind-Adresse: standardmäßig ein Unix-Socket relativ zum WorkingDirectory.
|
||||||
|
# nginx spricht denselben Socket per absoluter Pfadangabe an
|
||||||
|
# (z.B. /home/projekthub/projekt-hub/projekt-hub.sock).
|
||||||
|
bind = os.environ.get("GUNICORN_BIND", "unix:projekt-hub.sock")
|
||||||
|
|
||||||
|
# Worker-Anzahl: Faustregel (2 × CPU-Kerne) + 1, per env begrenzbar.
|
||||||
|
workers = int(os.environ.get("GUNICORN_WORKERS", str(multiprocessing.cpu_count() * 2 + 1)))
|
||||||
|
worker_class = os.environ.get("GUNICORN_WORKER_CLASS", "sync")
|
||||||
|
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
# Socket-Dateirechte, damit nginx (Gruppe www-data) zugreifen kann.
|
||||||
|
umask = 0o007
|
||||||
|
|
||||||
|
# Logs auf stdout/stderr → landen via systemd im journal (journalctl -u projekt-hub).
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info")
|
||||||
|
|
||||||
|
proc_name = "projekt-hub"
|
||||||
0
instance/.gitkeep
Normal file
0
instance/.gitkeep
Normal file
1
migrations/README
Normal file
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""initial schema: users, login_codes, projects, tasks
|
||||||
|
|
||||||
|
Revision ID: cc4f09c8a12f
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-11 02:25:05.478589
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cc4f09c8a12f'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
|
||||||
|
|
||||||
|
op.create_table('login_codes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('login_codes', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_login_codes_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('projects',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_projects_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('tasks',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('project_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('text', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('done', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('tasks', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_tasks_project_id'), ['project_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_tasks_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('tasks', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_tasks_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_tasks_project_id'))
|
||||||
|
|
||||||
|
op.drop_table('tasks')
|
||||||
|
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_projects_user_id'))
|
||||||
|
|
||||||
|
op.drop_table('projects')
|
||||||
|
with op.batch_alter_table('login_codes', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_login_codes_user_id'))
|
||||||
|
|
||||||
|
op.drop_table('login_codes')
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_users_email'))
|
||||||
|
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
873
reference/projekte.html
Normal file
873
reference/projekte.html
Normal file
|
|
@ -0,0 +1,873 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Projekte</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--surface: #16213e;
|
||||||
|
--card: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent2: #f5a623;
|
||||||
|
--text: #eaeaea;
|
||||||
|
--muted: #8892a4;
|
||||||
|
--done: #3a4a5c;
|
||||||
|
--done-text: #566272;
|
||||||
|
--overdue: #ff6b6b;
|
||||||
|
--soon: #f5a623;
|
||||||
|
--ok: #4ecca3;
|
||||||
|
--border: rgba(255,255,255,0.07);
|
||||||
|
--font: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 span { color: var(--accent); }
|
||||||
|
|
||||||
|
.today {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats bar ── */
|
||||||
|
.stats {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong { color: var(--text); font-size: 1.1rem; display: block; }
|
||||||
|
|
||||||
|
/* ── Add form ── */
|
||||||
|
.add-form {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form .row {
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form input[type="text"],
|
||||||
|
.add-form input[type="date"],
|
||||||
|
.add-form select {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: .55rem .8rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form input[type="text"] { flex: 1; min-width: 180px; }
|
||||||
|
.add-form input[type="date"] { width: 145px; color-scheme: dark; }
|
||||||
|
.add-form select { width: 150px; }
|
||||||
|
|
||||||
|
.add-form input:focus, .add-form select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .55rem 1.2rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover { opacity: .85; }
|
||||||
|
|
||||||
|
/* ── Project management ── */
|
||||||
|
.project-mgmt {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-mgmt input[type="text"] {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: .4rem .7rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-mgmt input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .4rem .8rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:hover { background: var(--card); }
|
||||||
|
|
||||||
|
/* ── Sections ── */
|
||||||
|
.section {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
margin-bottom: .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
font-size: .7rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .75rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Project card ── */
|
||||||
|
.project-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7rem;
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .85rem;
|
||||||
|
padding: .2rem .4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Task item ── */
|
||||||
|
.task-list { padding: .4rem 0; }
|
||||||
|
|
||||||
|
.task {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .7rem;
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:last-child { border-bottom: none; }
|
||||||
|
.task:hover { background: rgba(255,255,255,.03); }
|
||||||
|
|
||||||
|
.task.done { opacity: .5; }
|
||||||
|
|
||||||
|
.task input[type="checkbox"] {
|
||||||
|
margin-top: .15rem;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.task-text {
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task.done .task-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--done-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: .25rem;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-overdue { background: rgba(255,107,107,.18); color: var(--overdue); }
|
||||||
|
.due-soon { background: rgba(245,166,35,.18); color: var(--soon); }
|
||||||
|
.due-ok { background: rgba(78,204,163,.12); color: var(--ok); }
|
||||||
|
.due-none { background: var(--card); color: var(--muted); }
|
||||||
|
|
||||||
|
.task-project-tag {
|
||||||
|
font-size: .68rem;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-left: .3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .1rem .3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s, color .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover .task-delete { opacity: 1; }
|
||||||
|
.task-delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── All tasks section ── */
|
||||||
|
.all-tasks-list .task { border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .85rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter bar ── */
|
||||||
|
.filter-bar {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto .75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: .3rem .8rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover:not(.active) { border-color: var(--muted); color: var(--text); }
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
.divider {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sync bar ── */
|
||||||
|
.sync-bar {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .9rem 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-label {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sync {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .4rem .9rem;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sync:hover { background: #1a3a5c; border-color: var(--accent); }
|
||||||
|
.btn-sync.export { border-color: var(--ok); color: var(--ok); }
|
||||||
|
.btn-sync.export:hover { background: rgba(78,204,163,.1); }
|
||||||
|
.btn-sync.import { border-color: var(--accent2); color: var(--accent2); }
|
||||||
|
.btn-sync.import:hover { background: rgba(245,166,35,.1); }
|
||||||
|
|
||||||
|
.sync-hint {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(80px);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .6rem 1.2rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform .25s ease, opacity .25s ease;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 100;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-toast.show {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Projekte <span>&</span> Aufgaben</h1>
|
||||||
|
<div class="today" id="today-date"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
|
||||||
|
<!-- Sync bar -->
|
||||||
|
<div class="sync-bar">
|
||||||
|
<span class="sync-label">↕ Sync</span>
|
||||||
|
<button class="btn-sync export" onclick="exportData()">⬇ Exportieren</button>
|
||||||
|
<button class="btn-sync import" onclick="document.getElementById('import-file').click()">⬆ Importieren</button>
|
||||||
|
<input type="file" id="import-file" accept=".json" style="display:none" onchange="importData(event)">
|
||||||
|
<span class="sync-hint">Exportieren → auf Proton Drive laden → auf anderem Gerät importieren</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sync-toast" class="sync-toast"></div>
|
||||||
|
|
||||||
|
<!-- Add task -->
|
||||||
|
<div class="add-form">
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="new-task-text" placeholder="Neue Aufgabe …" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<select id="new-task-project"></select>
|
||||||
|
<input type="date" id="new-task-due" />
|
||||||
|
<button class="btn-add" onclick="addTask()">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project management -->
|
||||||
|
<div class="project-mgmt">
|
||||||
|
<span style="font-size:.78rem;color:var(--muted)">Projekt:</span>
|
||||||
|
<input type="text" id="new-project-name" placeholder="Name …" />
|
||||||
|
<button class="btn-small" onclick="addProject()">+ Projekt anlegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects section -->
|
||||||
|
<div class="section" id="projects-section">
|
||||||
|
<div class="section-header" onclick="toggleSection('projects-body')">
|
||||||
|
<span class="section-title">Nach Projekt</span>
|
||||||
|
<span class="section-count" id="proj-count">0</span>
|
||||||
|
<button class="collapse-btn" id="projects-collapse-btn">▲</button>
|
||||||
|
</div>
|
||||||
|
<div id="projects-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- All tasks section -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Alle Aufgaben</span>
|
||||||
|
<span class="section-count" id="all-count">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<span class="filter-label">Zeigen:</span>
|
||||||
|
<button class="filter-btn active" onclick="setFilter('all', this)">Alle</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('open', this)">Offen</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('overdue', this)">Überfällig</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('soon', this)">Diese Woche</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('done', this)">Erledigt</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-card">
|
||||||
|
<div class="task-list all-tasks-list" id="all-tasks-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────
|
||||||
|
const STORE_KEY = 'projekte_v1';
|
||||||
|
|
||||||
|
const PROJECT_COLORS = [
|
||||||
|
'#e94560','#f5a623','#4ecca3','#a78bfa','#60a5fa',
|
||||||
|
'#f472b6','#34d399','#fb923c','#818cf8','#38bdf8'
|
||||||
|
];
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
projects: [
|
||||||
|
{ id: 'p1', name: 'Beispielprojekt', color: PROJECT_COLORS[0] }
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
{ id: 't1', text: 'Diese Aufgabe abhaken ✓', project: 'p1', due: '', done: false, created: Date.now() }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let activeFilter = 'all';
|
||||||
|
let collapsedProjects = {};
|
||||||
|
let sectionCollapsed = false;
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
localStorage.setItem(STORE_KEY, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const raw = localStorage.getItem(STORE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
try { data = JSON.parse(raw); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IDs ───────────────────────────────────────────────────────────────
|
||||||
|
function uid() { return 'id_' + Math.random().toString(36).slice(2); }
|
||||||
|
|
||||||
|
// ── Date helpers ──────────────────────────────────────────────────────
|
||||||
|
function today() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const t = new Date(today());
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return Math.round((d - t) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dueBadge(dateStr, done) {
|
||||||
|
if (!dateStr) return `<span class="task-due due-none">kein Datum</span>`;
|
||||||
|
const diff = daysUntil(dateStr);
|
||||||
|
const [y, m, dd] = dateStr.split('-');
|
||||||
|
const label = `${dd}.${m}.${y.slice(2)}`;
|
||||||
|
if (done) return `<span class="task-due due-none">${label}</span>`;
|
||||||
|
if (diff < 0) return `<span class="task-due due-overdue">⚠ ${label} (${Math.abs(diff)}d über)</span>`;
|
||||||
|
if (diff <= 7) return `<span class="task-due due-soon">⏳ ${label} (${diff}d)</span>`;
|
||||||
|
return `<span class="task-due due-ok">📅 ${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ──────────────────────────────────────────────────────────
|
||||||
|
function addProject() {
|
||||||
|
const inp = document.getElementById('new-project-name');
|
||||||
|
const name = inp.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const color = PROJECT_COLORS[data.projects.length % PROJECT_COLORS.length];
|
||||||
|
data.projects.push({ id: uid(), name, color });
|
||||||
|
inp.value = '';
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProject(id) {
|
||||||
|
if (!confirm('Projekt löschen? Aufgaben bleiben erhalten.')) return;
|
||||||
|
data.projects = data.projects.filter(p => p.id !== id);
|
||||||
|
data.tasks.forEach(t => { if (t.project === id) t.project = ''; });
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectById(id) {
|
||||||
|
return data.projects.find(p => p.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ─────────────────────────────────────────────────────────────
|
||||||
|
function addTask() {
|
||||||
|
const textEl = document.getElementById('new-task-text');
|
||||||
|
const projEl = document.getElementById('new-task-project');
|
||||||
|
const dueEl = document.getElementById('new-task-due');
|
||||||
|
const text = textEl.value.trim();
|
||||||
|
if (!text) { textEl.focus(); return; }
|
||||||
|
data.tasks.push({
|
||||||
|
id: uid(),
|
||||||
|
text,
|
||||||
|
project: projEl.value,
|
||||||
|
due: dueEl.value,
|
||||||
|
done: false,
|
||||||
|
created: Date.now()
|
||||||
|
});
|
||||||
|
textEl.value = '';
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
textEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDone(id) {
|
||||||
|
const t = data.tasks.find(t => t.id === id);
|
||||||
|
if (t) { t.done = !t.done; save(); render(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTask(id) {
|
||||||
|
data.tasks = data.tasks.filter(t => t.id !== id);
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter ────────────────────────────────────────────────────────────
|
||||||
|
function setFilter(f, btn) {
|
||||||
|
activeFilter = f;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(tasks) {
|
||||||
|
const tod = today();
|
||||||
|
const weekOut = new Date(tod);
|
||||||
|
weekOut.setDate(weekOut.getDate() + 7);
|
||||||
|
const weekStr = weekOut.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (activeFilter === 'open') return tasks.filter(t => !t.done);
|
||||||
|
if (activeFilter === 'done') return tasks.filter(t => t.done);
|
||||||
|
if (activeFilter === 'overdue') return tasks.filter(t => !t.done && t.due && t.due < tod);
|
||||||
|
if (activeFilter === 'soon') return tasks.filter(t => !t.done && t.due && t.due >= tod && t.due <= weekStr);
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collapse ──────────────────────────────────────────────────────────
|
||||||
|
function toggleSection(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const btn = document.getElementById('projects-collapse-btn');
|
||||||
|
sectionCollapsed = !sectionCollapsed;
|
||||||
|
el.style.display = sectionCollapsed ? 'none' : '';
|
||||||
|
btn.textContent = sectionCollapsed ? '▼' : '▲';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProject(pid) {
|
||||||
|
collapsedProjects[pid] = !collapsedProjects[pid];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
function buildTaskEl(task, showProject) {
|
||||||
|
const proj = projectById(task.project);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'task' + (task.done ? ' done' : '');
|
||||||
|
|
||||||
|
let projTag = '';
|
||||||
|
if (showProject && proj) {
|
||||||
|
projTag = `<span class="task-project-tag" style="background:${proj.color}22;color:${proj.color}">${proj.name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" ${task.done ? 'checked' : ''} onchange="toggleDone('${task.id}')">
|
||||||
|
<div class="task-body">
|
||||||
|
<span class="task-text">${escHtml(task.text)}</span>${projTag}
|
||||||
|
<div>${dueBadge(task.due, task.done)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="task-delete" onclick="deleteTask('${task.id}')" title="Löschen">✕</button>
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
// Today
|
||||||
|
document.getElementById('today-date').textContent =
|
||||||
|
new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long', year:'numeric' });
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const open = data.tasks.filter(t => !t.done).length;
|
||||||
|
const done = data.tasks.filter(t => t.done).length;
|
||||||
|
const overdue = data.tasks.filter(t => !t.done && t.due && t.due < today()).length;
|
||||||
|
document.getElementById('stats').innerHTML = `
|
||||||
|
<div class="stat"><strong>${open}</strong>Offen</div>
|
||||||
|
<div class="stat"><strong>${done}</strong>Erledigt</div>
|
||||||
|
<div class="stat"><strong style="color:var(--overdue)">${overdue}</strong>Überfällig</div>
|
||||||
|
<div class="stat"><strong>${data.projects.length}</strong>Projekte</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Project selector in form
|
||||||
|
const sel = document.getElementById('new-task-project');
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— kein Projekt —</option>' +
|
||||||
|
data.projects.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||||
|
if (prev) sel.value = prev;
|
||||||
|
|
||||||
|
// Projects body
|
||||||
|
const pb = document.getElementById('projects-body');
|
||||||
|
pb.innerHTML = '';
|
||||||
|
let totalProjOpen = 0;
|
||||||
|
|
||||||
|
data.projects.forEach(proj => {
|
||||||
|
const tasks = data.tasks.filter(t => t.project === proj.id);
|
||||||
|
const openT = tasks.filter(t => !t.done).length;
|
||||||
|
totalProjOpen += openT;
|
||||||
|
const collapsed = collapsedProjects[proj.id];
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'project-card';
|
||||||
|
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'project-card-header';
|
||||||
|
hdr.innerHTML = `
|
||||||
|
<span class="project-dot" style="background:${proj.color}"></span>
|
||||||
|
<span class="project-name">${escHtml(proj.name)}</span>
|
||||||
|
<span class="project-meta">${openT} offen · ${tasks.length} gesamt</span>
|
||||||
|
<button class="project-delete" onclick="event.stopPropagation();deleteProject('${proj.id}')" title="Projekt löschen">✕</button>
|
||||||
|
`;
|
||||||
|
hdr.onclick = () => { collapsedProjects[proj.id] = !collapsed; render(); };
|
||||||
|
card.appendChild(hdr);
|
||||||
|
|
||||||
|
if (!collapsed) {
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'task-list';
|
||||||
|
const sorted = [...tasks].sort((a,b) => {
|
||||||
|
if (a.done !== b.done) return a.done ? 1 : -1;
|
||||||
|
if (!a.due && !b.due) return 0;
|
||||||
|
if (!a.due) return 1;
|
||||||
|
if (!b.due) return -1;
|
||||||
|
return a.due.localeCompare(b.due);
|
||||||
|
});
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-hint">Keine Aufgaben — oben hinzufügen.</div>';
|
||||||
|
} else {
|
||||||
|
sorted.forEach(t => list.appendChild(buildTaskEl(t, false)));
|
||||||
|
}
|
||||||
|
card.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.projects.length === 0) {
|
||||||
|
pb.innerHTML = '<div class="empty-hint">Noch keine Projekte — oben anlegen.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('proj-count').textContent = totalProjOpen + ' offen';
|
||||||
|
|
||||||
|
// All tasks
|
||||||
|
const allList = document.getElementById('all-tasks-list');
|
||||||
|
allList.innerHTML = '';
|
||||||
|
const filtered = applyFilter([...data.tasks]).sort((a,b) => {
|
||||||
|
if (a.done !== b.done) return a.done ? 1 : -1;
|
||||||
|
if (!a.due && !b.due) return 0;
|
||||||
|
if (!a.due) return 1;
|
||||||
|
if (!b.due) return -1;
|
||||||
|
return a.due.localeCompare(b.due);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('all-count').textContent = filtered.length;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
allList.innerHTML = '<div class="empty-hint">Keine Aufgaben in dieser Ansicht.</div>';
|
||||||
|
} else {
|
||||||
|
filtered.forEach(t => allList.appendChild(buildTaskEl(t, true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enter key ─────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('new-task-text').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') addTask();
|
||||||
|
});
|
||||||
|
document.getElementById('new-project-name').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') addProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Export / Import ───────────────────────────────────────────────────
|
||||||
|
function exportData() {
|
||||||
|
const filename = 'projekte_' + today() + '.json';
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('✓ Exportiert als ' + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importData(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(e.target.result);
|
||||||
|
if (!imported.projects || !imported.tasks) throw new Error('Ungültiges Format');
|
||||||
|
const existing = data.tasks.length + data.projects.length;
|
||||||
|
const msg = existing > 0
|
||||||
|
? `Import ersetzt ${data.tasks.length} Aufgaben und ${data.projects.length} Projekte durch die importierten Daten. Fortfahren?`
|
||||||
|
: 'Daten importieren?';
|
||||||
|
if (existing === 0 || confirm(msg)) {
|
||||||
|
data = imported;
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
showToast('✓ ' + imported.tasks.length + ' Aufgaben importiert');
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
showToast('⚠ Fehler: Datei konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const el = document.getElementById('sync-toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.add('show');
|
||||||
|
setTimeout(() => el.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────
|
||||||
|
load();
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
Flask>=3.0
|
||||||
|
Flask-SQLAlchemy>=3.1
|
||||||
|
Flask-Migrate>=4.0
|
||||||
|
Flask-Login>=0.6
|
||||||
|
Flask-Mail>=0.9
|
||||||
|
Flask-Limiter>=3.5
|
||||||
|
Flask-WTF>=1.2
|
||||||
|
email-validator>=2.1
|
||||||
|
fpdf2>=2.7
|
||||||
|
python-dotenv>=1.0
|
||||||
|
gunicorn>=21.2
|
||||||
|
# Optional: nur genutzt, wenn RATELIMIT_STORAGE_URI auf redis:// zeigt (Produktion).
|
||||||
|
redis>=5.0
|
||||||
16
run.py
Normal file
16
run.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Lokaler Dev-Server.
|
||||||
|
|
||||||
|
Lädt .env, erzeugt die App via Factory und startet den Flask-Dev-Server.
|
||||||
|
Aufruf: python run.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from app import create_app # noqa: E402
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="127.0.0.1", port=5000, debug=True)
|
||||||
12
wsgi.py
Normal file
12
wsgi.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""WSGI-Einstiegspunkt für Produktion (gunicorn).
|
||||||
|
|
||||||
|
Aufruf z.B.: gunicorn --bind 127.0.0.1:8000 wsgi:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from app import create_app # noqa: E402
|
||||||
|
|
||||||
|
app = create_app("production")
|
||||||
Loading…
Add table
Reference in a new issue