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
This commit is contained in:
SimolZimol
2026-08-02 20:41:24 +02:00
commit 0e7efe4b74
25 changed files with 1590 additions and 0 deletions

15
.env.example Normal file
View File

@@ -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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.egg-info/
.env
.venv/
venv/
.DS_Store

33
Dockerfile Normal file
View File

@@ -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"]

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# 📓 Bullet Journal (Flask)
Eine eigene **Bullet-Journal-Webanwendung** mit Benutzerkonten, Tagesseiten (MontagSonntag),
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** (MontagSonntag) 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.

46
app.py Normal file
View File

@@ -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)

75
auth.py Normal file
View File

@@ -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

28
config.py Normal file
View File

@@ -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"

76
db.py Normal file
View File

@@ -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()

41
db_helpers.py Normal file
View File

@@ -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.")

36
docker-compose.yml Normal file
View File

@@ -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:

5
requirements.txt Normal file
View File

@@ -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

0
routes/__init__.py Normal file
View File

112
routes/admin_routes.py Normal file
View File

@@ -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/<int:user_id>/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/<int:user_id>/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"))

35
routes/auth_routes.py Normal file
View File

@@ -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"))

325
routes/journal_routes.py Normal file
View File

@@ -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: MonSo 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/<date_str>", 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/<int:note_id>/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/<int:note_id>/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"))

192
static/css/style.css Normal file
View File

@@ -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; }
}

27
static/js/main.js Normal file
View File

@@ -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);
});

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Admin Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Admin-Panel</h1>
<p class="muted">Zentrales Dashboard</p>
</div>
<a href="{{ url_for('admin.users') }}" class="btn">Benutzer verwalten</a>
</div>
<section class="stat-grid">
<div class="stat-card">
<span class="stat-number">{{ total_users }}</span>
<span class="stat-label">Benutzer</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ total_admins }}</span>
<span class="stat-label">Admins</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ total_notes }}</span>
<span class="stat-label">Notizen</span>
</div>
</section>
<section class="card">
<h2>Kürzlich angelegte Benutzer</h2>
{% if recent %}
<table class="table">
<thead>
<tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Erstellt</th></tr>
</thead>
<tbody>
{% for u in recent %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.email or '' }}</td>
<td>{{ 'Admin' if u.is_admin else 'Benutzer' }}</td>
<td>{{ u.created_at.strftime('%d.%m.%Y %H:%M') if u.created_at else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Noch keine Benutzer.</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Benutzerverwaltung Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Benutzerverwaltung</h1>
<p class="muted">Accounts erstellen und verwalten</p>
</div>
</div>
<section class="card">
<h2>Neuen Benutzer anlegen</h2>
<form method="post" action="{{ url_for('admin.users') }}" class="note-form">
<div class="note-form-row">
<label>
Benutzername
<input type="text" name="username" required>
</label>
<label>
Passwort
<input type="text" name="password" required>
</label>
<label>
E-Mail
<input type="email" name="email">
</label>
<label class="checkbox-label">
<input type="checkbox" name="is_admin" value="1"> Admin
</label>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</section>
<section class="card">
<h2>Alle Benutzer</h2>
{% if users %}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Notizen</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email or '' }}</td>
<td>{{ 'Admin' if u.is_admin else 'Benutzer' }}</td>
<td>{{ u.note_count }}</td>
<td>{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }}</td>
<td class="table-actions">
<form method="post" action="{{ url_for('admin.reset_password', user_id=u.id) }}" class="inline">
<input type="text" name="new_password" placeholder="Neues Passwort" required>
<button class="btn btn-small">PW zurücksetzen</button>
</form>
{% if u.id != session.get('user_id') %}
<form method="post" action="{{ url_for('admin.delete_user', user_id=u.id) }}" class="inline"
onsubmit="return confirm('Benutzer wirklich löschen? Alle Notizen gehen verloren.');">
<button class="btn btn-small btn-danger">Löschen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Keine Benutzer vorhanden.</p>
{% endif %}
</section>
{% endblock %}

52
templates/base.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Bullet Journal{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a href="{{ url_for('journal.index') }}" class="brand">📓 Bullet Journal</a>
{% if session.get('user_id') %}
<nav class="nav">
<a href="{{ url_for('journal.index') }}">Dashboard</a>
<a href="{{ url_for('journal.week') }}">Wochenübersicht</a>
<a href="{{ url_for('journal.future') }}">Future Log</a>
<a href="{{ url_for('journal.day', date_str=today_str) }}" class="nav-today">Heute</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin.dashboard') }}" class="nav-admin">Admin</a>
{% endif %}
</nav>
<div class="user-area">
<span class="username">{{ session.get('username') }}</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<button type="submit" class="btn btn-link">Abmelden</button>
</form>
</div>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>Bullet Journal &copy; {{ now.year if now else 2026 }}</p>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

81
templates/day.html Normal file
View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}{{ weekday }} Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>{{ weekday }}, {{ target.strftime('%d.%m.%Y') }}</h1>
<p class="muted">
<a href="{{ url_for('journal.day', date_str=prev.strftime('%Y-%m-%d')) }}" class="btn btn-small">← Vortag</a>
{% if target.strftime('%Y-%m-%d') != today_str %}
<a href="{{ url_for('journal.day', date_str=today_str) }}" class="btn btn-small">Heute</a>
{% endif %}
<a href="{{ url_for('journal.day', date_str=nxt.strftime('%Y-%m-%d')) }}" class="btn btn-small">Nächster Tag →</a>
</p>
</div>
</div>
<section class="card">
<h2>Neue Notiz</h2>
<form method="post" action="{{ url_for('journal.day', date_str=target.strftime('%Y-%m-%d')) }}" class="note-form">
<div class="note-form-row">
<label>
Bullet
<select name="bullet">
<option value=".">• Aufgabe</option>
<option value="x">✕ Termin</option>
<option value="-"> Notiz</option>
<option value="?">? Frage</option>
</select>
</label>
<label class="grow">
Notiz
<input type="text" name="note_text" required placeholder="Was ist zu tun?">
</label>
<label>
Priorität
<select name="priority">
<option value="0">Niedrig</option>
<option value="1">Mittel</option>
<option value="2">Hoch</option>
</select>
</label>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</section>
<section class="card">
<h2>Notizen</h2>
{% if notes %}
<div class="notes-table">
<div class="notes-header">
<span>Bullet</span>
<span>Notiz</span>
<span>Priorität</span>
<span>Aktionen</span>
</div>
{% for n in notes %}
<div class="notes-row {{ 'is-done' if n.status == 'done' else '' }}">
<span class="bullet bullet-{{ n.bullet_type }}" title="{{ bullet_labels.get(n.bullet_type, '') }}">{{ n.bullet_type }}</span>
<span class="note-text">{{ n.note_text }}</span>
<span class="priority-badge priority-{{ n.priority }}">{{ ['Niedrig','Mittel','Hoch'][n.priority] if n.priority <= 2 else n.priority }}</span>
<span class="actions">
<form method="post" action="{{ url_for('journal.mark_done', note_id=n.id) }}" class="inline">
<button class="btn btn-small {{ 'btn-success' if n.status != 'done' else '' }}">
{{ 'Erledigt' if n.status != 'done' else 'Wieder öffnen' }}
</button>
</form>
<form method="post" action="{{ url_for('journal.move_note', note_id=n.id) }}" class="inline move-form">
<input type="date" name="target_date" class="move-date" title="In anderen Tag verschieben">
<button type="submit" class="btn btn-small" name="target" value="date">Verschieben</button>
<button type="submit" class="btn btn-small btn-future" name="target" value="future">→ Future Log</button>
</form>
</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Noch keine Notizen für diesen Tag.</p>
{% endif %}
</section>
{% endblock %}

50
templates/future.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Future Log Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Future Log</h1>
<p class="muted">Langfristiger Kalender Aufgaben, die später erledigt werden.</p>
</div>
</div>
<section class="card">
<h2>Neuer Eintrag</h2>
<form method="post" action="{{ url_for('journal.future') }}" class="note-form">
<div class="note-form-row">
<label>
Monat
<input type="month" name="month" required>
</label>
<label class="grow">
Notiz
<input type="text" name="note_text" required placeholder="Eintrag ins Future Log">
</label>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</section>
{% if grouped %}
{% for month_key, entries in grouped.items() %}
<section class="card">
<h2>{{ month_key[5:7]|int }} / {{ month_key[0:4] }}</h2>
<ul class="note-list">
{% for e in entries %}
<li class="note-item {{ 'is-done' if e.status == 'done' else '' }}">
<span class="bullet">{{ '✓' if e.status == 'done' else '' }}</span>
<span class="note-text">{{ e.note_text }}</span>
<form method="post" action="{{ url_for('journal.future') }}" class="inline">
<input type="hidden" name="toggle_id" value="{{ e.id }}">
</form>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% else %}
<section class="card">
<p class="muted">Noch keine Einträge im Future Log.</p>
</section>
{% endif %}
{% endblock %}

49
templates/index.html Normal file
View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Dashboard Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Dashboard</h1>
<p class="muted">Woche ab {{ monday.strftime('%d.%m.%Y') }}</p>
</div>
<a href="{{ url_for('journal.week') }}" class="btn">Zur Wochenübersicht</a>
</div>
<section class="card">
<h2>Diese Woche</h2>
<div class="week-grid">
{% for day in day_summary %}
<a class="day-card" href="{{ url_for('journal.day', date_str=day.date.strftime('%Y-%m-%d')) }}">
<span class="day-name">{{ day.weekday }}</span>
<span class="day-date">{{ day.date.strftime('%d.%m.') }}</span>
<span class="day-stats">
{% if day.total > 0 %}
<span class="pill open">{{ day.open }} offen</span>
<span class="pill done">{{ day.done }} erledigt</span>
{% else %}
<span class="pill none">leer</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
</section>
{% if overdue %}
<section class="card">
<h2>Überfällige Aufgaben</h2>
<ul class="note-list">
{% for n in overdue %}
<li class="note-item">
<span class="bullet bullet-{{ n.bullet_type|urlencode }}">{{ n.bullet_type }}</span>
<span class="note-text">{{ n.note_text }}</span>
<span class="note-date">{{ n.note_date.strftime('%d.%m.%Y') }}</span>
<form method="post" action="{{ url_for('journal.mark_done', note_id=n.id) }}" class="inline">
<button class="btn btn-small">Erledigt</button>
</form>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %}

19
templates/login.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Anmelden Bullet Journal{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Anmelden</h1>
<p class="muted">Bitte melde dich mit deinem Konto an.</p>
<form method="post" action="{{ url_for('auth.login') }}" class="stack">
<label>
Benutzername
<input type="text" name="username" required autofocus>
</label>
<label>
Passwort
<input type="password" name="password" required>
</label>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
</div>
{% endblock %}

61
templates/week.html Normal file
View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Wochenübersicht Bullet Journal{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Wochenübersicht</h1>
<p class="muted">
<a href="{{ url_for('journal.week', week=prev_monday.strftime('%G-W%V')) }}" class="btn btn-small">← Vorige</a>
Woche ab {{ monday.strftime('%d.%m.%Y') }}
<a href="{{ url_for('journal.week', week=next_monday.strftime('%G-W%V')) }}" class="btn btn-small">Nächste →</a>
</p>
</div>
</div>
<section class="card">
<table class="table">
<thead>
<tr>
<th>Tag</th>
<th>Datum</th>
<th>Offen</th>
<th>Erledigt</th>
<th>Gesamt</th>
<th></th>
</tr>
</thead>
<tbody>
{% for day in days_detail %}
<tr>
<td>{{ day.weekday }}</td>
<td>{{ day.date.strftime('%d.%m.%Y') }}</td>
<td class="num open">{{ day.open }}</td>
<td class="num done">{{ day.done }}</td>
<td class="num">{{ day.total }}</td>
<td><a href="{{ url_for('journal.day', date_str=day.date.strftime('%Y-%m-%d')) }}" class="btn btn-small">Öffnen</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Offene Aufgaben dieser Woche</h2>
{% if open_notes %}
<ul class="note-list">
{% for n in open_notes %}
<li class="note-item">
<span class="bullet bullet-{{ n.bullet_type }}">{{ n.bullet_type }}</span>
<span class="note-text">{{ n.note_text }}</span>
<span class="note-date">{{ 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')) }}</span>
<form method="post" action="{{ url_for('journal.mark_done', note_id=n.id) }}" class="inline">
<button class="btn btn-small">Erledigt</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Keine offenen Aufgaben in dieser Woche. 🎉</p>
{% endif %}
</section>
{% endblock %}