commit 0e7efe4b74b3e1ce1dac32aa298f116394380abe Author: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Sun Aug 2 20:41:24 2026 +0200 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 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 %}