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:
15
.env.example
Normal file
15
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal 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
97
README.md
Normal file
@@ -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.
|
||||||
46
app.py
Normal file
46
app.py
Normal 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
75
auth.py
Normal 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
28
config.py
Normal 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
76
db.py
Normal 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
41
db_helpers.py
Normal 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
36
docker-compose.yml
Normal 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
5
requirements.txt
Normal 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
0
routes/__init__.py
Normal file
112
routes/admin_routes.py
Normal file
112
routes/admin_routes.py
Normal 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
35
routes/auth_routes.py
Normal 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
325
routes/journal_routes.py
Normal 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: 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/<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
192
static/css/style.css
Normal 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
27
static/js/main.js
Normal 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);
|
||||||
|
});
|
||||||
49
templates/admin/dashboard.html
Normal file
49
templates/admin/dashboard.html
Normal 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 %}
|
||||||
79
templates/admin/users.html
Normal file
79
templates/admin/users.html
Normal 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
52
templates/base.html
Normal 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 © {{ 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
81
templates/day.html
Normal 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
50
templates/future.html
Normal 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
49
templates/index.html
Normal 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
19
templates/login.html
Normal 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
61
templates/week.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user