From 0e7efe4b74b3e1ce1dac32aa298f116394380abe Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:41:24 +0200 Subject: [PATCH] new file: .env.example new file: .gitignore new file: Dockerfile new file: README.md new file: app.py new file: auth.py new file: config.py new file: db.py new file: db_helpers.py new file: docker-compose.yml new file: requirements.txt new file: routes/__init__.py new file: routes/admin_routes.py new file: routes/auth_routes.py new file: routes/journal_routes.py new file: static/css/style.css new file: static/js/main.js new file: templates/admin/dashboard.html new file: templates/admin/users.html new file: templates/base.html new file: templates/day.html new file: templates/future.html new file: templates/index.html new file: templates/login.html new file: templates/week.html --- .env.example | 15 ++ .gitignore | 7 + Dockerfile | 33 ++++ README.md | 97 ++++++++++ app.py | 46 +++++ auth.py | 75 ++++++++ config.py | 28 +++ db.py | 76 ++++++++ db_helpers.py | 41 +++++ docker-compose.yml | 36 ++++ requirements.txt | 5 + routes/__init__.py | 0 routes/admin_routes.py | 112 ++++++++++++ routes/auth_routes.py | 35 ++++ routes/journal_routes.py | 325 +++++++++++++++++++++++++++++++++ static/css/style.css | 192 +++++++++++++++++++ static/js/main.js | 27 +++ templates/admin/dashboard.html | 49 +++++ templates/admin/users.html | 79 ++++++++ templates/base.html | 52 ++++++ templates/day.html | 81 ++++++++ templates/future.html | 50 +++++ templates/index.html | 49 +++++ templates/login.html | 19 ++ templates/week.html | 61 +++++++ 25 files changed, 1590 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 auth.py create mode 100644 config.py create mode 100644 db.py create mode 100644 db_helpers.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/admin_routes.py create mode 100644 routes/auth_routes.py create mode 100644 routes/journal_routes.py create mode 100644 static/css/style.css create mode 100644 static/js/main.js create mode 100644 templates/admin/dashboard.html create mode 100644 templates/admin/users.html create mode 100644 templates/base.html create mode 100644 templates/day.html create mode 100644 templates/future.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/week.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c42cda3 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# MySQL Verbindung +DB_HOST=localhost +DB_PORT=3306 +DB_USER=journal +DB_PASSWORD=journalpass +DB_DATABASE=journal + +# Flask / Sicherheit +# -> In Coolify als Secrets hinterlegen (nicht im Code!) +SECRET_KEY=please-change-me-strong-secret +PEPPER=please-change-me-pepper-value + +# Erster Admin (wird beim ersten Start automatisch angelegt) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=please-change-me-admin-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f08c767 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.venv/ +venv/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2876c42 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Basis-Image mit Python +FROM python:3.11-slim + +# Arbeitsverzeichnis erstellen +WORKDIR /app + +# Kopiere die requirements-Datei und installiere die Abhängigkeiten +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Kopiere den gesamten Projektinhalt in das Arbeitsverzeichnis +COPY . . + +# Umgebungsvariablen von Coolify / docker-compose übernehmen (zur Laufzeit) +ENV DB_HOST=$DB_HOST +ENV DB_PORT=$DB_PORT +ENV DB_USER=$DB_USER +ENV DB_PASSWORD=$DB_PASSWORD +ENV DB_DATABASE=$DB_DATABASE +ENV SECRET_KEY=$SECRET_KEY +ENV PEPPER=$PEPPER +ENV ADMIN_USERNAME=$ADMIN_USERNAME +ENV ADMIN_PASSWORD=$ADMIN_PASSWORD + +# Nicht-root Benutzer aus Sicherheitsgründen +RUN useradd --create-home appuser && chown -R appuser:appuser /app +USER appuser + +# Port für Gunicorn +EXPOSE 8000 + +# Startbefehl (Production: Gunicorn) +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fe7cf5 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# 📓 Bullet Journal (Flask) + +Eine eigene **Bullet-Journal-Webanwendung** mit Benutzerkonten, Tagesseiten (Montag–Sonntag), +Wochenübersicht und Future Log. Läuft in **Docker** und ist für **Coolify** optimiert. +Die Daten liegen in **MySQL**. + +## Features + +- 🔐 **Login-System** mit **jedem eigenen Journal** pro Benutzer (Daten-Isolation) +- 📅 **7 Tagesseiten** (Montag–Sonntag) mit 3 Spalten: **Bullet | Notiz | Priorität** +- 🗓 **Wochenübersicht** (offene / erledigte Aufgaben je Tag) +- 📌 **Future Log** – langfristiger Kalender +- ✅ Aufgaben **erledigt** markieren, in einen anderen Tag oder ins **Future Log verschieben** +- 🛡 **Admin-Panel** zum Erstellen und Verwalten von Accounts +- 🔒 Passwörter mit **Salz + Pfeffer** gehasht (PBKDF2-SHA256) + +## Bullet-Typen + +| Symbol | Bedeutung | +|--------|-----------| +| `.` | Aufgabe | +| `x` | Termin | +| `-` | Notiz | +| `?` | Frage | + +Prioritäten: `0` Niedrig, `1` Mittel, `2` Hoch. + +## Projektstruktur + +``` +Journal/ +├── Dockerfile # Production-Image (Gunicorn) +├── docker-compose.yml # Lokale Entwicklung (App + MySQL) +├── requirements.txt +├── .env.example +├── app.py # Flask-App + Blueprints +├── config.py # Konfiguration aus Env-Variablen +├── db.py # PyMySQL + Schema-Init +├── db_helpers.py # Benutzer-/Admin-Helfer +├── auth.py # Passwort-Hash (Salz+Pfeffer), Guards +├── routes/ +│ ├── auth_routes.py # Login / Logout +│ ├── journal_routes.py # Index, Wochenübersicht, Tage, Future Log +│ └── admin_routes.py # Admin-Panel +├── templates/ # Jinja2-Seiten +└── static/ # eigenes CSS + JS +``` + +## Lokale Entwicklung + +1. **Umgebungsvariablen** kopieren: + ```bash + copy .env.example .env + ``` + `SECRET_KEY`, `PEPPER` sowie `ADMIN_USERNAME` / `ADMIN_PASSWORD` anpassen. + +2. **Starten** (App + MySQL): + ```bash + docker compose up --build + ``` + +3. Öffnen: http://localhost:8000 + +Der **erste Admin** wird beim Start automatisch aus `ADMIN_USERNAME` / `ADMIN_PASSWORD` angelegt. +Melde dich damit an und lege im **Admin-Panel** weitere Benutzer an. + +## Deployment auf Coolify + +1. Repository in Coolify als **Public Repository** verknüpfen. +2. **MySQL** als Dienst hinzufügen (Coolify verbindet die Folgenden automatisch als Env-Variablen). +3. Env-Variablen als **Secrets** setzen: + + | Variable | Beschreibung | + |-------------------|-------------------------------------------------| + | `DB_HOST` | Host der MySQL (von Coolify gesetzt) | + | `DB_PORT` | Port der MySQL (von Coolify gesetzt) | + | `DB_USER` | DB-Benutzer (von Coolify gesetzt) | + | `DB_PASSWORD` | DB-Passwort (von Coolify gesetzt) | + | `DB_DATABASE` | DB-Name (von Coolify gesetzt) | + | `SECRET_KEY` | Geheimer Wert für Flask-Sessions (**Secret**) | + | `PEPPER` | Globaler „Pfeffer“ für Passwort-Hashing (**Secret** – wird NIE in der DB gespeichert) | + | `ADMIN_USERNAME` | Name des ersten Admins | + | `ADMIN_PASSWORD` | Passwort des ersten Admins (**Secret**) | + +4. Port `8000` freigeben. TLS/Reverse-Proxy übernimmt Coolify automatisch. + +> **Wichtig:** Ändere `ADMIN_PASSWORD`, `SECRET_KEY` und `PEPPER` unbedingt – verwende keine +> Standardwerte. `PEPPER` darf nach dem ersten Hash-Vorgang nicht mehr geändert werden, +> sonst lassen sich bestehende Passwörter nicht mehr prüfen. + +## Sicherheit + +- Passwörter werden mit zufälligem **Salz** (pro Benutzer, in DB) und einem globalen **Pfeffer** + (aus der Umgebung, nie in der DB) über **PBKDF2-SHA256** gehasht. +- Jede Abfrage ist über die Session an den angemeldeten Benutzer (`user_id`) gebunden. +- Seitenzugriffe sind durch `@login_required` / `@admin_required` geschützt. +- Es gibt **keine** offene Registrierung – nur Admins legen Accounts an. diff --git a/app.py b/app.py new file mode 100644 index 0000000..a157941 --- /dev/null +++ b/app.py @@ -0,0 +1,46 @@ +"""Einstiegspunkt der Flask-App. Registriert alle Blueprints.""" +from datetime import date, datetime + +from flask import Flask + +from config import Config +from db import init_db + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + # Blueprints registrieren + from routes.auth_routes import auth_bp + from routes.journal_routes import journal_bp + from routes.admin_routes import admin_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(journal_bp) + app.register_blueprint(admin_bp) + + # Template/Filter-Helfer in Kontext verfügbar machen + @app.context_processor + def inject_globals(): + today = date.today() + return { + "now": datetime.now(), + "today_str": today.strftime("%Y-%m-%d"), + } + + # Schema anlegen + Bootstrap-Admin beim ersten Start + with app.app_context(): + from db import get_connection + from db_helpers import ensure_admin + init_db() + ensure_admin(app) + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, debug=False) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fd04e83 --- /dev/null +++ b/auth.py @@ -0,0 +1,75 @@ +"""Passwort-Hashing mit Salz + Pfeffer sowie Session-/Auth-Helfer. + +Das Passwort wird wie folgt gehasht: + hash = pbkdf2_hmac('sha256', (klartext + salz + pfeffer), iterationen) + +- Salz: zufälliger Wert pro Benutzer (in DB gespeichert). +- Pfeffer: globaler geheimer Wert aus der Umgebung (NICHT in der DB). +""" +import hashlib +import hmac +import os +from base64 import b64encode, b64decode +from functools import wraps + +from flask import session, redirect, url_for, flash + +from config import Config + + +def _salt_bytes_to_str(raw: bytes) -> str: + return b64encode(raw).decode("ascii") + + +def _salt_str_to_bytes(salt: str) -> bytes: + return b64decode(salt.encode("ascii")) + + +def hash_password(password: str) -> tuple[str, str]: + """Erzeugt (salt, password_hash) für ein neues Passwort.""" + salt_raw = os.urandom(16) + salt = _salt_bytes_to_str(salt_raw) + digest = hashlib.pbkdf2_hmac( + "sha256", + (password + Config.PEPPER).encode("utf-8"), + salt_raw, + Config.HASH_ITERATIONS, + ) + return salt, digest.hex() + + +def verify_password(password: str, salt: str, password_hash: str) -> bool: + """Prüft ein Passwort gegen salt + password_hash.""" + salt_raw = _salt_str_to_bytes(salt) + digest = hashlib.pbkdf2_hmac( + "sha256", + (password + Config.PEPPER).encode("utf-8"), + salt_raw, + Config.HASH_ITERATIONS, + ) + return hmac.compare_digest(digest.hex(), password_hash) + + +def login_required(view): + """Dekorator: nur eingeloggte Benutzer.""" + @wraps(view) + def wrapped(*args, **kwargs): + if "user_id" not in session: + flash("Bitte melde dich zuerst an.", "warning") + return redirect(url_for("auth.login")) + return view(*args, **kwargs) + return wrapped + + +def admin_required(view): + """Dekorator: nur Admins.""" + @wraps(view) + def wrapped(*args, **kwargs): + if "user_id" not in session: + flash("Bitte melde dich zuerst an.", "warning") + return redirect(url_for("auth.login")) + if not session.get("is_admin"): + flash("Keine Berechtigung für diesen Bereich.", "danger") + return redirect(url_for("journal.index")) + return view(*args, **kwargs) + return wrapped diff --git a/config.py b/config.py new file mode 100644 index 0000000..9aa23bd --- /dev/null +++ b/config.py @@ -0,0 +1,28 @@ +"""Zentrale Konfiguration aus Umgebungsvariablen.""" +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """Liest alle Einstellungen aus Umgebungsvariablen (Coolify).""" + + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") + PEPPER = os.getenv("PEPPER", "dev-pepper-change-me") + + DB_HOST = os.getenv("DB_HOST", "localhost") + DB_PORT = int(os.getenv("DB_PORT", "3306")) + DB_USER = os.getenv("DB_USER", "journal") + DB_PASSWORD = os.getenv("DB_PASSWORD", "journalpass") + DB_DATABASE = os.getenv("DB_DATABASE", "journal") + + # Bootstrap-Admin + ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") + ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") + + # PBKDF2 Iterationen für das Passwort-Hashing + HASH_ITERATIONS = 100_000 + + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" diff --git a/db.py b/db.py new file mode 100644 index 0000000..4104dbd --- /dev/null +++ b/db.py @@ -0,0 +1,76 @@ +"""Datenbankzugriff (PyMySQL) + automatisches Schema-Init. + +Verwendung: + from db import get_connection, init_db +""" +import pymysql +from pymysql.cursors import DictCursor + +from config import Config + + +def get_connection(): + """Öffnet eine neue Verbindung zur MySQL-Datenbank.""" + return pymysql.connect( + host=Config.DB_HOST, + port=Config.DB_PORT, + user=Config.DB_USER, + password=Config.DB_PASSWORD, + database=Config.DB_DATABASE, + charset="utf8mb4", + cursorclass=DictCursor, + autocommit=True, + ) + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255), + salt VARCHAR(128) NOT NULL, + password_hash VARCHAR(512) NOT NULL, + is_admin TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS notes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + note_date DATE NOT NULL, + bullet_type ENUM('.', 'x', '-', '?') NOT NULL DEFAULT '-', + note_text TEXT NOT NULL, + priority TINYINT NOT NULL DEFAULT 0, + status ENUM('open', 'done', 'moved') NOT NULL DEFAULT 'open', + moved_to_date DATE NULL, + moved_to_future TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_notes_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS future_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + month_date DATE NOT NULL, + note_text TEXT NOT NULL, + status ENUM('open', 'done') NOT NULL DEFAULT 'open', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_future_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +""" + + +def init_db(): + """Erstellt das Schema, falls es noch nicht existiert.""" + conn = get_connection() + try: + with conn.cursor() as cur: + for statement in SCHEMA.split(";"): + stmt = statement.strip() + if stmt: + cur.execute(stmt) + finally: + conn.close() diff --git a/db_helpers.py b/db_helpers.py new file mode 100644 index 0000000..1b83892 --- /dev/null +++ b/db_helpers.py @@ -0,0 +1,41 @@ +r"""Gemeinsame DB-Zugriffe (Benutzer + Admin-Bootstrap).""" +from db import get_connection +from config import Config +from auth import hash_password + + +def get_user_by_username(username: str) -> dict | None: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT * FROM users WHERE username = %s", (username,)) + return cur.fetchone() + + +def get_user_by_id(user_id: int) -> dict | None: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + return cur.fetchone() + + +def create_user(username: str, password: str, email: str = "", is_admin: bool = False) -> None: + salt, password_hash = hash_password(password) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO users (username, email, salt, password_hash, is_admin) " + "VALUES (%s, %s, %s, %s, %s)", + (username, email, salt, password_hash, 1 if is_admin else 0), + ) + + +def ensure_admin(app): + """Legt beim ersten Start den Admin aus den Env-Variablen an, falls keiner existiert.""" + with app.app_context(): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS c FROM users WHERE is_admin = 1") + count = cur.fetchone()["c"] + if count == 0: + create_user(Config.ADMIN_USERNAME, Config.ADMIN_PASSWORD, is_admin=True) + print(f"Admin-Benutzer '{Config.ADMIN_USERNAME}' angelegt.") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eb05c58 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.8" + +services: + db: + image: mysql:8.0 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-rootpassword} + MYSQL_DATABASE: ${DB_DATABASE:-journal} + MYSQL_USER: ${DB_USER:-journal} + MYSQL_PASSWORD: ${DB_PASSWORD:-journalpass} + volumes: + - db_data:/var/lib/mysql + ports: + - "3306:3306" + + web: + build: . + restart: unless-stopped + depends_on: + - db + environment: + DB_HOST: db + DB_PORT: 3306 + DB_USER: ${DB_USER:-journal} + DB_PASSWORD: ${DB_PASSWORD:-journalpass} + DB_DATABASE: ${DB_DATABASE:-journal} + SECRET_KEY: ${SECRET_KEY:-change-me-please} + PEPPER: ${PEPPER:-change-me-pepper} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + ports: + - "8000:8000" + +volumes: + db_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7cb7203 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +PyMySQL==1.1.1 +Werkzeug==3.0.3 +gunicorn==22.0.0 +python-dotenv==1.0.1 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin_routes.py b/routes/admin_routes.py new file mode 100644 index 0000000..416913d --- /dev/null +++ b/routes/admin_routes.py @@ -0,0 +1,112 @@ +"""Admin-Routen: Dashboard, Benutzerverwaltung (erstellen, löschen, Passwort zurücksetzen).""" +from flask import Blueprint, render_template, request, redirect, url_for, flash, session + +from auth import admin_required, hash_password +from db import get_connection +from db_helpers import get_user_by_id + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + + +@admin_bp.route("/") +@admin_required +def dashboard(): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS total FROM users") + total_users = cur.fetchone()["total"] + cur.execute("SELECT COUNT(*) AS total FROM notes") + total_notes = cur.fetchone()["total"] + cur.execute("SELECT COUNT(*) AS c FROM users WHERE is_admin = 1") + total_admins = cur.fetchone()["c"] + cur.execute( + "SELECT username, email, is_admin, created_at FROM users " + "ORDER BY created_at DESC LIMIT 5" + ) + recent = cur.fetchall() + return render_template( + "admin/dashboard.html", + total_users=total_users, + total_notes=total_notes, + total_admins=total_admins, + recent=recent, + ) + + +@admin_bp.route("/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + email = request.form.get("email", "").strip() + is_admin = 1 if request.form.get("is_admin") else 0 + + if not username or not password: + flash("Benutzername und Passwort sind Pflichtfelder.", "danger") + else: + try: + salt, password_hash = hash_password(password) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO users (username, email, salt, " + "password_hash, is_admin) VALUES (%s, %s, %s, %s, %s)", + (username, email, salt, password_hash, is_admin), + ) + flash(f"Benutzer '{username}' angelegt.", "success") + except Exception as exc: # Duplicate Key etc. + flash(f"Benutzer konnte nicht angelegt werden: {exc}", "danger") + return redirect(url_for("admin.users")) + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT u.*, (SELECT COUNT(*) FROM notes n WHERE n.user_id = u.id) " + "AS note_count FROM users u ORDER BY u.username ASC" + ) + user_list = cur.fetchall() + return render_template("admin/users.html", users=user_list) + + +@admin_bp.route("/users//delete", methods=["POST"]) +@admin_required +def delete_user(user_id: int): + # Admin darf sich nicht selbst löschen + target = get_user_by_id(user_id) + if not target: + flash("Benutzer nicht gefunden.", "danger") + return redirect(url_for("admin.users")) + if target["id"] == session.get("user_id"): + flash("Du kannst dich nicht selbst löschen.", "danger") + return redirect(url_for("admin.users")) + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + flash(f"Benutzer '{target['username']}' gelöscht.", "success") + return redirect(url_for("admin.users")) + + +@admin_bp.route("/users//reset-password", methods=["POST"]) +@admin_required +def reset_password(user_id: int): + new_password = request.form.get("new_password", "") + if not new_password: + flash("Neues Passwort fehlt.", "danger") + return redirect(url_for("admin.users")) + + target = get_user_by_id(user_id) + if not target: + flash("Benutzer nicht gefunden.", "danger") + return redirect(url_for("admin.users")) + + salt, password_hash = hash_password(new_password) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE users SET salt = %s, password_hash = %s WHERE id = %s", + (salt, password_hash, user_id), + ) + flash(f"Passwort von '{target['username']}' zurückgesetzt.", "success") + return redirect(url_for("admin.users")) diff --git a/routes/auth_routes.py b/routes/auth_routes.py new file mode 100644 index 0000000..cc1ee2b --- /dev/null +++ b/routes/auth_routes.py @@ -0,0 +1,35 @@ +"""Auth-Routen: Login und Logout.""" +from flask import Blueprint, render_template, request, redirect, url_for, session, flash + +from auth import verify_password +from db_helpers import get_user_by_username + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + user = get_user_by_username(username) + if user and verify_password(password, user["salt"], user["password_hash"]): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + flash(f"Willkommen, {user['username']}!", "success") + if user["is_admin"]: + return redirect(url_for("admin.dashboard")) + return redirect(url_for("journal.index")) + flash("Ungültiger Benutzername oder Passwort.", "danger") + + return render_template("login.html") + + +@auth_bp.route("/logout", methods=["POST"]) +def logout(): + session.clear() + flash("Du wurdest abgemeldet.", "info") + return redirect(url_for("auth.login")) diff --git a/routes/journal_routes.py b/routes/journal_routes.py new file mode 100644 index 0000000..c47a3ed --- /dev/null +++ b/routes/journal_routes.py @@ -0,0 +1,325 @@ +"""Journal-Routen: Index/Dashboard, Future Log, Wochenübersicht, Tagesansicht. + +Enthält auch Helfer für Wochenberechnung und Notiz-Aktionen +(erledigt markieren, in anderen Tag / Future Log verschieben). +""" +from datetime import date, datetime, timedelta + +from flask import Blueprint, render_template, request, redirect, url_for, session, flash + +from auth import login_required +from db import get_connection + +journal_bp = Blueprint("journal", __name__) + +# Deutsche Wochentage / Monate +WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +MONTHS = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] +BULLET_LABELS = { + ".": "Aufgabe", + "x": "Termin", + "-": "Notiz", + "?": "Frage", +} + + +def _user_id() -> int: + return session["user_id"] + + +def monday_of_week(d: date) -> date: + """Liefert den Montag der Woche, in der d liegt.""" + return d - timedelta(days=d.weekday()) + + +def get_week_days(monday: date) -> list[date]: + return [monday + timedelta(days=i) for i in range(7)] + + +def parse_date(s: str) -> date | None: + try: + return datetime.strptime(s, "%Y-%m-%d").date() + except (ValueError, TypeError): + return None + + +def _fetch_notes(user_id: int, note_date: date) -> list[dict]: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM notes WHERE user_id = %s AND note_date = %s " + "ORDER BY id ASC", + (user_id, note_date), + ) + return cur.fetchall() + + +def _active_notes(user_id: int, note_date: date) -> list[dict]: + return [n for n in _fetch_notes(user_id, note_date) if n["status"] != "moved"] + + +@journal_bp.route("/") +@login_required +def index(): + """Dashboard: aktuelle Woche + offene Aufgaben.""" + today = date.today() + monday = monday_of_week(today) + week_days = get_week_days(monday) + + # Detaillierte Info pro Tag + day_summary = [] + for d in week_days: + notes = _active_notes(_user_id(), d) + day_summary.append( + { + "date": d, + "weekday": WEEKDAYS[d.weekday()], + "open": sum(1 for n in notes if n["status"] == "open"), + "done": sum(1 for n in notes if n["status"] == "done"), + "total": len(notes), + } + ) + + # Offene Aufgaben außerhalb dieser Woche + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM notes WHERE user_id = %s AND status = 'open' " + "AND note_date < %s ORDER BY note_date ASC LIMIT 50", + (_user_id(), monday), + ) + overdue = cur.fetchall() + + return render_template( + "index.html", + today=today, + monday=monday, + day_summary=day_summary, + overdue=overdue, + weekdays=WEEKDAYS, + ) + + +@journal_bp.route("/future", methods=["GET", "POST"]) +@login_required +def future(): + if request.method == "POST": + note_text = request.form.get("note_text", "").strip() + month_str = request.form.get("month", "") + month = parse_date(month_str) + if note_text and month: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO future_log (user_id, month_date, note_text) " + "VALUES (%s, %s, %s)", + (_user_id(), month, note_text), + ) + flash("Eintrag ins Future Log hinzugefügt.", "success") + else: + flash("Bitte Text und Monat angeben.", "danger") + return redirect(url_for("journal.future")) + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM future_log WHERE user_id = %s ORDER BY month_date ASC, id ASC", + (_user_id(),), + ) + entries = cur.fetchall() + + # Gruppieren nach Monat + grouped = {} + for e in entries: + key = e["month_date"].strftime("%Y-%m") + grouped.setdefault(key, []).append(e) + + return render_template("future.html", grouped=grouped, months=MONTHS) + + +@journal_bp.route("/woche") +@login_required +def week(): + """Wochenübersicht: Mon–So mit Status je Tag + offene Aufgaben.""" + week_param = request.args.get("week", "") + if week_param: + try: + iso_year, iso_week = week_param.split("-W") + monday = date.fromisocalendar(int(iso_year), int(iso_week), 1) + except (ValueError, TypeError): + monday = monday_of_week(date.today()) + else: + monday = monday_of_week(date.today()) + + week_days = get_week_days(monday) + + days_detail = [] + for d in week_days: + notes = _active_notes(_user_id(), d) + days_detail.append( + { + "date": d, + "weekday": WEEKDAYS[d.weekday()], + "open": sum(1 for n in notes if n["status"] == "open"), + "done": sum(1 for n in notes if n["status"] == "done"), + "moved": sum(1 for n in notes if n["status"] == "moved"), + "total": len(notes), + } + ) + + # Offene Aufgaben der ausgewählten Woche + sunday = monday + timedelta(days=6) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM notes WHERE user_id = %s AND status = 'open' " + "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC", + (_user_id(), monday, sunday), + ) + open_notes = cur.fetchall() + + prev_monday = monday - timedelta(days=7) + next_monday = monday + timedelta(days=7) + + return render_template( + "week.html", + monday=monday, + days_detail=days_detail, + open_notes=open_notes, + weekdays=WEEKDAYS, + prev_monday=prev_monday, + next_monday=next_monday, + ) + + +@journal_bp.route("/tag/", methods=["GET", "POST"]) +@login_required +def day(date_str: str): + """Tagesansicht mit 3 Spalten (Bullet | Notiz | Priorität).""" + target = parse_date(date_str) + if target is None: + flash("Ungültiges Datum.", "danger") + return redirect(url_for("journal.index")) + + if request.method == "POST": + bullet = request.form.get("bullet", "-") + note_text = request.form.get("note_text", "").strip() + priority = request.form.get("priority", "0") + try: + priority = int(priority) + except ValueError: + priority = 0 + + if bullet not in (".", "x", "-", "?"): + bullet = "-" + if note_text: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO notes (user_id, note_date, bullet_type, " + "note_text, priority) VALUES (%s, %s, %s, %s, %s)", + (_user_id(), target, bullet, note_text, priority), + ) + flash("Notiz hinzugefügt.", "success") + else: + flash("Notiz darf nicht leer sein.", "danger") + return redirect(url_for("journal.day", date_str=date_str)) + + notes = _active_notes(_user_id(), target) + + prev = target - timedelta(days=1) + nxt = target + timedelta(days=1) + + return render_template( + "day.html", + target=target, + weekday=WEEKDAYS[target.weekday()], + notes=notes, + bullet_labels=BULLET_LABELS, + prev=prev, + nxt=nxt, + ) + + +@journal_bp.route("/note//mark", methods=["POST"]) +@login_required +def mark_done(note_id: int): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM notes WHERE id = %s AND user_id = %s", + (note_id, _user_id()), + ) + note = cur.fetchone() + if not note: + flash("Notiz nicht gefunden.", "danger") + return redirect(url_for("journal.index")) + new_status = "done" if note["status"] != "done" else "open" + cur.execute( + "UPDATE notes SET status = %s WHERE id = %s", + (new_status, note_id), + ) + flash("Status aktualisiert.", "success") + return redirect(request.referrer or url_for("journal.index")) + + +@journal_bp.route("/note//move", methods=["POST"]) +@login_required +def move_note(note_id: int): + """Verschiebt eine Notiz in einen anderen Tag oder ins Future Log. + + Formularfelder: target_date (YYYY-MM-DD) oder target=future + """ + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM notes WHERE id = %s AND user_id = %s", + (note_id, _user_id()), + ) + note = cur.fetchone() + if not note: + flash("Notiz nicht gefunden.", "danger") + return redirect(url_for("journal.index")) + + target = request.form.get("target", "") + if target == "future": + # In Future Log übernehmen + cur.execute( + "INSERT INTO future_log (user_id, month_date, note_text) " + "VALUES (%s, %s, %s)", + (_user_id(), note["note_date"], note["note_text"]), + ) + cur.execute( + "UPDATE notes SET status = 'moved', moved_to_future = 1 " + "WHERE id = %s", + (note_id,), + ) + flash("In Future Log verschoben.", "success") + else: + target_date = parse_date(target) + if target_date: + cur.execute( + "UPDATE notes SET status = 'moved', moved_to_date = %s " + "WHERE id = %s", + (target_date, note_id), + ) + # Am Zieltag als offene Notiz neu anlegen + cur.execute( + "INSERT INTO notes (user_id, note_date, bullet_type, " + "note_text, priority) VALUES (%s, %s, %s, %s, %s)", + ( + _user_id(), + target_date, + note["bullet_type"], + note["note_text"], + note["priority"], + ), + ) + flash(f"Verschoben nach {target_date}.", "success") + else: + flash("Kein gültiges Ziel angegeben.", "danger") + + return redirect(request.referrer or url_for("journal.index")) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..d674e24 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,192 @@ +/* Bullet Journal – eigenes Design */ +:root { + --bg: #f7f4ee; + --paper: #fffdf7; + --ink: #2b2b2b; + --muted: #8a867e; + --line: #e5ded2; + --accent: #c9a227; + --accent-dark: #a8871d; + --green: #2e7d32; + --red: #c62828; + --blue: #1565c0; + --shadow: 0 1px 3px rgba(0,0,0,.08); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--ink); + line-height: 1.5; +} + +/* Topbar */ +.topbar { background: var(--ink); color: #fff; } +.topbar-inner { + max-width: 960px; + margin: 0 auto; + padding: .8rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.brand { color: #fff; text-decoration: none; font-weight: 700; font-size: 1.1rem; } +.nav { display: flex; gap: .5rem; flex-wrap: wrap; } +.nav a { color: #e0dccf; text-decoration: none; padding: .3rem .6rem; border-radius: 4px; } +.nav a:hover { background: rgba(255,255,255,.1); color: #fff; } +.nav-admin { color: var(--accent) !important; font-weight: 600; } +.user-area { display: flex; align-items: center; gap: .8rem; } +.username { color: #e0dccf; font-size: .9rem; } + +/* Layout */ +.container { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; } +.page-head { + display: flex; align-items: center; justify-content: space-between; + gap: 1rem; margin-bottom: 1.2rem; flex-wrap: wrap; +} +h1 { margin: 0 0 .2rem; font-size: 1.6rem; } +h2 { font-size: 1.1rem; margin: 0 0 .8rem; color: var(--accent-dark); } +.muted { color: var(--muted); } + +/* Cards */ +.card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 8px; + padding: 1.2rem; + margin-bottom: 1.2rem; + box-shadow: var(--shadow); +} + +/* Flash */ +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; } +.flash-success { background: #e8f5e9; color: var(--green); border: 1px solid #a5d6a7; } +.flash-danger { background: #fdecea; color: var(--red); border: 1px solid #ef9a9a; } +.flash-warning { background: #fff8e1; color: #8d6e00; border: 1px solid #ffe082; } +.flash-info { background: #e3f2fd; color: var(--blue); border: 1px solid #90caf9; } + +/* Buttons */ +.btn { + display: inline-block; cursor: pointer; border: 1px solid var(--line); + background: #fff; color: var(--ink); padding: .5rem .9rem; + border-radius: 6px; text-decoration: none; font-size: .9rem; + line-height: 1.2; +} +.btn:hover { background: #f6f1e6; } +.btn-primary { background: var(--accent); border-color: var(--accent-dark); color: #fff; } +.btn-primary:hover { background: var(--accent-dark); } +.btn-small { padding: .3rem .6rem; font-size: .8rem; } +.btn-danger { background: var(--red); border-color: var(--red); color: #fff; } +.btn-danger:hover { background: #b71c1c; } +.btn-success { background: var(--green); border-color: var(--green); color: #fff; } +.btn-success:hover { background: #1b5e20; } +.btn-future { background: #f0e6c8; border-color: var(--accent); } +.btn-link { background: none; border: none; color: #e0dccf; cursor: pointer; font-size: .9rem; } +.btn-link:hover { text-decoration: underline; } + +/* Formulare */ +.stack { display: flex; flex-direction: column; gap: .8rem; } +label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; } +input, select { + padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; + font-size: .95rem; background: #fff; color: var(--ink); +} +input:focus, select:focus { outline: 2px solid var(--accent); } +.grow { flex: 1; } +.note-form-row { display: flex; gap: .8rem; align-items: flex-end; flex-wrap: wrap; } +.note-form-row label { flex: 0 1 auto; } +.checkbox-label { flex-direction: row; align-items: center; gap: .4rem; } +.inline { display: inline-flex; gap: .4rem; align-items: center; } +.inline .btn { } + +/* Wöchentliche Übersicht (Dashboard) */ +.week-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: .6rem; } +.day-card { + background: #fdfaf2; border: 1px solid var(--line); border-radius: 8px; + padding: .6rem; text-align: center; text-decoration: none; color: var(--ink); + display: flex; flex-direction: column; gap: .3rem; +} +.day-card:hover { border-color: var(--accent); background: #fffdf4; } +.day-name { font-weight: 600; font-size: .8rem; } +.day-date { font-size: .75rem; color: var(--muted); } +.day-stats { display: flex; flex-direction: column; gap: .2rem; margin-top: auto; } +.pill { font-size: .7rem; padding: .15rem .4rem; border-radius: 20px; } +.pill.open { background: #fff3cd; color: #8d6e00; } +.pill.done { background: #e8f5e9; color: var(--green); } +.pill.none { background: #f0ece3; color: var(--muted); } + +/* Notiz-Liste */ +.note-list { list-style: none; margin: 0; padding: 0; } +.note-item { + display: flex; align-items: center; gap: .7rem; + padding: .5rem 0; border-bottom: 1px dashed var(--line); +} +.note-item:last-child { border-bottom: none; } +.bullet { font-weight: 700; width: 1.4rem; text-align: center; font-size: 1.05rem; } +.bullet-\\. { color: var(--ink); } +.bullet-x { color: var(--red); } +.bullet-- { color: var(--blue); } +.bullet-\\? { color: var(--accent-dark); } +.note-text { flex: 1; } +.note-date { color: var(--muted); font-size: .8rem; } +.note-item.is-done .note-text { text-decoration: line-through; color: var(--muted); } + +/* 3-Spalten Tabelle (Tagesansicht) */ +.notes-table { display: flex; flex-direction: column; } +.notes-header, .notes-row { + display: grid; grid-template-columns: 70px 1fr 110px 340px; gap: .6rem; + align-items: center; padding: .5rem .2rem; +} +.notes-header { font-weight: 600; font-size: .8rem; color: var(--muted); border-bottom: 2px solid var(--line); } +.notes-row { border-bottom: 1px dashed var(--line); } +.notes-row:last-child { border-bottom: none; } +.notes-row.is-done .note-text { text-decoration: line-through; color: var(--muted); } +.priority-badge { justify-self: center; font-size: .75rem; padding: .15rem .5rem; border-radius: 20px; } +.priority-0 { background: #eceff1; color: var(--muted); } +.priority-1 { background: #fff3cd; color: #8d6e00; } +.priority-2 { background: #fdecea; color: var(--red); } +.actions { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; } +.move-date { padding: .3rem; font-size: .8rem; width: 130px; } + +/* Tabellen */ +.table { width: 100%; border-collapse: collapse; font-size: .9rem; } +.table th, .table td { text-align: left; padding: .55rem .6rem; border-bottom: 1px solid var(--line); } +.table th { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .02em; } +.num { text-align: center; } +.open { color: #b26a00; font-weight: 600; } +.done { color: var(--green); font-weight: 600; } +.table-actions { display: flex; gap: .4rem; align-items: center; } + +/* Statistik */ +.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.2rem; } +.stat-card { + background: var(--paper); border: 1px solid var(--line); border-radius: 8px; + padding: 1.2rem; text-align: center; box-shadow: var(--shadow); +} +.stat-number { display: block; font-size: 2rem; font-weight: 700; color: var(--accent-dark); } +.stat-label { color: var(--muted); font-size: .85rem; } + +/* Auth */ +.auth-card { + max-width: 380px; margin: 3rem auto; background: var(--paper); + border: 1px solid var(--line); border-radius: 10px; padding: 2rem; + box-shadow: var(--shadow); +} +.auth-card h1 { text-align: center; } + +/* Footer */ +.footer { text-align: center; padding: 1.5rem; color: var(--muted); font-size: .8rem; } + +/* Responsive */ +@media (max-width: 720px) { + .week-grid { grid-template-columns: repeat(2, 1fr); } + .notes-header, .notes-row { grid-template-columns: 40px 1fr; } + .notes-header span:nth-child(3), .notes-header span:nth-child(4), + .notes-row .priority-badge, .notes-row .actions { grid-column: 2; } + .stat-grid { grid-template-columns: 1fr; } +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..ef6e2c2 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,27 @@ +/* Bullet Journal – kleine Frontend-Helfer */ +document.addEventListener("DOMContentLoaded", function () { + // Move-Formular: wenn ein Datum gesetzt ist, Verschieben-Button nutzen + document.querySelectorAll(".move-form").forEach(function (form) { + var dateInput = form.querySelector("input[name='target_date']"); + var dateBtn = form.querySelector("button[name='target'][value='date']"); + // Deaktiviere den Datums-Button, solange kein Datum gewählt wurde + function updateBtn() { + if (dateBtn) { + dateBtn.disabled = !dateInput || !dateInput.value; + } + } + if (dateInput) { + dateInput.addEventListener("change", updateBtn); + updateBtn(); + } + }); + + // Flash-Nachrichten automatisch ausblenden + setTimeout(function () { + document.querySelectorAll(".flash").forEach(function (el) { + el.style.transition = "opacity .4s"; + el.style.opacity = "0"; + setTimeout(function () { el.remove(); }, 400); + }); + }, 5000); +}); diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..78b0299 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Admin – Bullet Journal{% endblock %} +{% block content %} +
+
+

Admin-Panel

+

Zentrales Dashboard

+
+ Benutzer verwalten +
+ +
+
+ {{ total_users }} + Benutzer +
+
+ {{ total_admins }} + Admins +
+
+ {{ total_notes }} + Notizen +
+
+ +
+

Kürzlich angelegte Benutzer

+ {% if recent %} + + + + + + {% for u in recent %} + + + + + + + {% endfor %} + +
BenutzernameE-MailRolleErstellt
{{ u.username }}{{ u.email or '–' }}{{ 'Admin' if u.is_admin else 'Benutzer' }}{{ u.created_at.strftime('%d.%m.%Y %H:%M') if u.created_at else '–' }}
+ {% else %} +

Noch keine Benutzer.

+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..6ecc417 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Benutzerverwaltung – Bullet Journal{% endblock %} +{% block content %} +
+
+

Benutzerverwaltung

+

Accounts erstellen und verwalten

+
+
+ +
+

Neuen Benutzer anlegen

+
+
+ + + + + +
+
+
+ +
+

Alle Benutzer

+ {% if users %} + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
IDBenutzernameE-MailRolleNotizenErstelltAktionen
{{ u.id }}{{ u.username }}{{ u.email or '–' }}{{ 'Admin' if u.is_admin else 'Benutzer' }}{{ u.note_count }}{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '–' }} +
+ + +
+ {% if u.id != session.get('user_id') %} +
+ +
+ {% endif %} +
+ {% else %} +

Keine Benutzer vorhanden.

+ {% endif %} +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2233788 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,52 @@ + + + + + + {% block title %}Bullet Journal{% endblock %} + + + +
+
+ 📓 Bullet Journal + {% if session.get('user_id') %} + +
+ {{ session.get('username') }} +
+ +
+
+ {% endif %} +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+

Bullet Journal © {{ now.year if now else 2026 }}

+
+ + +{% block scripts %}{% endblock %} + + diff --git a/templates/day.html b/templates/day.html new file mode 100644 index 0000000..bc0a1a5 --- /dev/null +++ b/templates/day.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}{{ weekday }} – Bullet Journal{% endblock %} +{% block content %} +
+
+

{{ weekday }}, {{ target.strftime('%d.%m.%Y') }}

+

+ ← Vortag + {% if target.strftime('%Y-%m-%d') != today_str %} + Heute + {% endif %} + Nächster Tag → +

+
+
+ +
+

Neue Notiz

+
+
+ + + + +
+
+
+ +
+

Notizen

+ {% if notes %} +
+
+ Bullet + Notiz + Priorität + Aktionen +
+ {% for n in notes %} +
+ {{ n.bullet_type }} + {{ n.note_text }} + {{ ['Niedrig','Mittel','Hoch'][n.priority] if n.priority <= 2 else n.priority }} + +
+ +
+
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

Noch keine Notizen für diesen Tag.

+ {% endif %} +
+{% endblock %} diff --git a/templates/future.html b/templates/future.html new file mode 100644 index 0000000..db5161a --- /dev/null +++ b/templates/future.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Future Log – Bullet Journal{% endblock %} +{% block content %} +
+
+

Future Log

+

Langfristiger Kalender – Aufgaben, die später erledigt werden.

+
+
+ +
+

Neuer Eintrag

+
+
+ + + +
+
+
+ +{% if grouped %} + {% for month_key, entries in grouped.items() %} +
+

{{ month_key[5:7]|int }} / {{ month_key[0:4] }}

+
    + {% for e in entries %} +
  • + {{ '✓' if e.status == 'done' else '›' }} + {{ e.note_text }} +
    + +
    +
  • + {% endfor %} +
+
+ {% endfor %} +{% else %} +
+

Noch keine Einträge im Future Log.

+
+{% endif %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..31cc3a3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Dashboard – Bullet Journal{% endblock %} +{% block content %} +
+
+

Dashboard

+

Woche ab {{ monday.strftime('%d.%m.%Y') }}

+
+ Zur Wochenübersicht +
+ +
+

Diese Woche

+ +
+ +{% if overdue %} +
+

Überfällige Aufgaben

+
    + {% for n in overdue %} +
  • + {{ n.bullet_type }} + {{ n.note_text }} + {{ n.note_date.strftime('%d.%m.%Y') }} +
    + +
    +
  • + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9923a5c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Anmelden – Bullet Journal{% endblock %} +{% block content %} +
+

Anmelden

+

Bitte melde dich mit deinem Konto an.

+
+ + + +
+
+{% endblock %} diff --git a/templates/week.html b/templates/week.html new file mode 100644 index 0000000..2555f00 --- /dev/null +++ b/templates/week.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Wochenübersicht – Bullet Journal{% endblock %} +{% block content %} +
+
+

Wochenübersicht

+

+ ← Vorige + Woche ab {{ monday.strftime('%d.%m.%Y') }} + Nächste → +

+
+
+ +
+ + + + + + + + + + + + + {% for day in days_detail %} + + + + + + + + + {% endfor %} + +
TagDatumOffenErledigtGesamt
{{ day.weekday }}{{ day.date.strftime('%d.%m.%Y') }}{{ day.open }}{{ day.done }}{{ day.total }}Öffnen
+
+ +
+

Offene Aufgaben dieser Woche

+ {% if open_notes %} +
    + {% for n in open_notes %} +
  • + {{ n.bullet_type }} + {{ n.note_text }} + {{ n.note_date.strftime('%d.%m.') }} {{ 'Aufgabe' if n.bullet_type == '.' else ('Termin' if n.bullet_type == 'x' else ('Notiz' if n.bullet_type == '-' else 'Frage')) }} +
    + +
    +
  • + {% endfor %} +
+ {% else %} +

Keine offenen Aufgaben in dieser Woche. 🎉

+ {% endif %} +
+{% endblock %}