From 21014c246e1cfa40b61cf78f373bb33b6ddc9990 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 12 Apr 2026 10:36:13 +0200 Subject: [PATCH] first commit --- .gitignore | 11 + README.md | 38 ++ nouri/__init__.py | 52 +++ nouri/auth.py | 143 ++++++ nouri/constants.py | 37 ++ nouri/db.py | 99 ++++ nouri/main.py | 724 +++++++++++++++++++++++++++++ nouri/schema.sql | 78 ++++ nouri/static/css/styles.css | 643 +++++++++++++++++++++++++ nouri/static/js/theme.js | 32 ++ nouri/templates/archive/list.html | 59 +++ nouri/templates/auth/login.html | 24 + nouri/templates/auth/setup.html | 32 ++ nouri/templates/base.html | 55 +++ nouri/templates/dashboard.html | 82 ++++ nouri/templates/home/list.html | 59 +++ nouri/templates/items/form.html | 78 ++++ nouri/templates/items/list.html | 77 +++ nouri/templates/planner/week.html | 81 ++++ nouri/templates/shopping/list.html | 52 +++ requirements.txt | 1 + wsgi.py | 4 + 22 files changed, 2461 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 nouri/__init__.py create mode 100644 nouri/auth.py create mode 100644 nouri/constants.py create mode 100644 nouri/db.py create mode 100644 nouri/main.py create mode 100644 nouri/schema.sql create mode 100644 nouri/static/css/styles.css create mode 100644 nouri/static/js/theme.js create mode 100644 nouri/templates/archive/list.html create mode 100644 nouri/templates/auth/login.html create mode 100644 nouri/templates/auth/setup.html create mode 100644 nouri/templates/base.html create mode 100644 nouri/templates/dashboard.html create mode 100644 nouri/templates/home/list.html create mode 100644 nouri/templates/items/form.html create mode 100644 nouri/templates/items/list.html create mode 100644 nouri/templates/planner/week.html create mode 100644 nouri/templates/shopping/list.html create mode 100644 requirements.txt create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6fa901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.DS_Store + +data/ +instance/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..011318f --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Nouri + +Nouri ist eine kleine private Flask-App fuer einen Haushalt, um Essensideen, Einkaeufe, vorhandene Lebensmittel und einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. + +## Merkmale in Version 0.1 + +- Lebensmittel und Mahlzeitenideen anlegen +- Fotos lokal hochladen +- Einkaufsliste mit Abhaken +- "Zuhause" als sichtbarer Vorrat +- Archiv zum spaeteren Wiederverwenden +- Tages- und Wochenplanung nach Tageszeiten +- einfache Benutzeranmeldung fuer einen Haushalt + +## Lokal starten + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +flask --app wsgi run --debug +``` + +Dann `http://127.0.0.1:5000` oeffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/setup` anlegen. + +## Konfiguration + +Die App legt Daten standardmaessig unter `./data` ab. + +Wichtige Umgebungsvariablen: + +- `NOURI_SECRET_KEY`: Session-Secret fuer Produktion +- `NOURI_DATA_DIR`: Pfad fuer Datenbank und Uploads, z. B. `/app/data` auf Cloudron +- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` + +## Cloudron-Hinweis + +Fuer Cloudron spaeter `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen. diff --git a/nouri/__init__.py b/nouri/__init__.py new file mode 100644 index 0000000..751333f --- /dev/null +++ b/nouri/__init__.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +import secrets +from pathlib import Path + +from flask import Flask, send_from_directory + +from . import db +from .auth import auth_bp +from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS +from .main import main_bp + + +def create_app() -> Flask: + root_dir = Path(__file__).resolve().parent.parent + data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() + upload_dir = data_dir / "uploads" + db_path = data_dir / "nouri.sqlite3" + + data_dir.mkdir(parents=True, exist_ok=True) + upload_dir.mkdir(parents=True, exist_ok=True) + + app = Flask(__name__, instance_relative_config=False) + app.config.update( + SECRET_KEY=os.environ.get("NOURI_SECRET_KEY", secrets.token_hex(24)), + DATABASE_PATH=str(db_path), + DATA_DIR=str(data_dir), + UPLOAD_FOLDER=str(upload_dir), + MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024, + ) + + db.init_app(app) + db.init_db_if_needed(app) + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + + @app.context_processor + def inject_globals() -> dict[str, object]: + return { + "item_kind_labels": ITEM_KIND_LABELS, + "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, + "category_suggestions": CATEGORIES, + "daypart_suggestions": DAYPARTS, + } + + @app.get("/uploads/") + def uploaded_file(filename: str): + return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + + return app diff --git a/nouri/auth.py b/nouri/auth.py new file mode 100644 index 0000000..808d0de --- /dev/null +++ b/nouri/auth.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import functools + +from flask import ( + Blueprint, + flash, + g, + redirect, + render_template, + request, + session, + url_for, +) +from markupsafe import Markup +from werkzeug.security import check_password_hash, generate_password_hash + +from .db import get_db, user_count + + +auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def login_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for("auth.login")) + return view(**kwargs) + + return wrapped_view + + +def ensure_csrf_token() -> str: + token = session.get("_csrf_token") + if not token: + token = session["_csrf_token"] = __import__("secrets").token_hex(24) + return token + + +@auth_bp.app_context_processor +def inject_csrf_input(): + return { + "csrf_input": lambda: Markup( + f'' + ) + } + + +@auth_bp.before_app_request +def load_logged_in_user(): + user_id = session.get("user_id") + if user_id is None: + g.user = None + else: + g.user = get_db().execute( + "SELECT * FROM users WHERE id = ?", + (user_id,), + ).fetchone() + + endpoint = request.endpoint or "" + if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}: + return redirect(url_for("auth.setup")) + + if request.method == "POST" and endpoint != "static": + token = session.get("_csrf_token") + form_token = request.form.get("csrf_token") + if not token or token != form_token: + flash("Die Sitzung muss kurz neu geladen werden. Bitte versuche es noch einmal.", "error") + return redirect(request.referrer or url_for("main.dashboard")) + + +@auth_bp.route("/setup", methods=("GET", "POST")) +def setup(): + if user_count() > 0: + return redirect(url_for("auth.login")) + + if request.method == "POST": + username = request.form.get("username", "").strip().lower() + display_name = request.form.get("display_name", "").strip() + password = request.form.get("password", "") + password_repeat = request.form.get("password_repeat", "") + + error = None + if not username: + error = "Bitte einen Benutzernamen eintragen." + elif not password: + error = "Bitte ein Passwort vergeben." + elif password != password_repeat: + error = "Die Passwoerter stimmen nicht ueberein." + + if error is None: + database = get_db() + database.execute( + """ + INSERT INTO users (username, display_name, password_hash) + VALUES (?, ?, ?) + """, + (username, display_name, generate_password_hash(password)), + ) + database.commit() + flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success") + return redirect(url_for("auth.login")) + + flash(error, "error") + + return render_template("auth/setup.html") + + +@auth_bp.route("/login", methods=("GET", "POST")) +def login(): + if user_count() == 0: + return redirect(url_for("auth.setup")) + + if request.method == "POST": + username = request.form.get("username", "").strip().lower() + password = request.form.get("password", "") + database = get_db() + user = database.execute( + "SELECT * FROM users WHERE username = ?", + (username,), + ).fetchone() + + error = None + if user is None or not check_password_hash(user["password_hash"], password): + error = "Benutzername oder Passwort passen nicht zusammen." + + if error is None: + session.clear() + session["user_id"] = user["id"] + ensure_csrf_token() + return redirect(url_for("main.dashboard")) + + flash(error, "error") + + return render_template("auth/login.html") + + +@auth_bp.post("/logout") +def logout(): + session.clear() + flash("Du bist abgemeldet.", "info") + return redirect(url_for("auth.login")) diff --git a/nouri/constants.py b/nouri/constants.py new file mode 100644 index 0000000..ed3bb45 --- /dev/null +++ b/nouri/constants.py @@ -0,0 +1,37 @@ +DAYPARTS = [ + {"slug": "breakfast", "name": "Fruehstueck", "sort_order": 10}, + {"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20}, + {"slug": "lunch", "name": "Mittagessen", "sort_order": 30}, + {"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40}, + {"slug": "dinner", "name": "Abendessen", "sort_order": 50}, + {"slug": "late-snack", "name": "Spaeter Snack", "sort_order": 60}, +] + +CATEGORIES = [ + "Brot & Getreide", + "Milchprodukt", + "Obst", + "Gemuese", + "Eiweissquelle", + "Snack", + "Getraenk", + "Vorrat & Basics", + "Warmes", + "Kleines Essen", +] + +ITEM_KIND_LABELS = { + "food": "Lebensmittel", + "meal": "Mahlzeitenideen", +} + +ITEM_KIND_SINGULAR_LABELS = { + "food": "Lebensmittel", + "meal": "Mahlzeitenidee", +} + +AVAILABILITY_LABELS = { + "idea": "Merkliste", + "home": "Zuhause", + "archived": "Archiv", +} diff --git a/nouri/db.py b/nouri/db.py new file mode 100644 index 0000000..1d84037 --- /dev/null +++ b/nouri/db.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import click +from flask import Flask, current_app, g +from flask.cli import with_appcontext +from werkzeug.security import generate_password_hash + +from .constants import DAYPARTS + + +def get_db() -> sqlite3.Connection: + if "db" not in g: + g.db = sqlite3.connect( + current_app.config["DATABASE_PATH"], + detect_types=sqlite3.PARSE_DECLTYPES, + ) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +def close_db(_error=None) -> None: + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db() -> None: + database = get_db() + schema_path = Path(__file__).with_name("schema.sql") + database.executescript(schema_path.read_text(encoding="utf-8")) + seed_dayparts(database) + database.commit() + + +def seed_dayparts(database: sqlite3.Connection) -> None: + for entry in DAYPARTS: + database.execute( + """ + INSERT OR IGNORE INTO dayparts (slug, name, sort_order) + VALUES (?, ?, ?) + """, + (entry["slug"], entry["name"], entry["sort_order"]), + ) + + +def init_db_if_needed(app: Flask) -> None: + db_path = Path(app.config["DATABASE_PATH"]) + needs_init = not db_path.exists() + with app.app_context(): + if needs_init: + init_db() + return + + database = get_db() + table = database.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'" + ).fetchone() + if table is None: + init_db() + + +def user_count() -> int: + row = get_db().execute("SELECT COUNT(*) AS count FROM users").fetchone() + return int(row["count"]) + + +@click.command("init-db") +@with_appcontext +def init_db_command() -> None: + init_db() + click.echo("Database initialized.") + + +@click.command("create-user") +@click.argument("username") +@click.argument("password") +@click.option("--display-name", default="", help="Friendly display name.") +@with_appcontext +def create_user_command(username: str, password: str, display_name: str) -> None: + database = get_db() + database.execute( + """ + INSERT INTO users (username, display_name, password_hash) + VALUES (?, ?, ?) + """, + (username.strip().lower(), display_name.strip(), generate_password_hash(password)), + ) + database.commit() + click.echo(f"User '{username}' created.") + + +def init_app(app: Flask) -> None: + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) + app.cli.add_command(create_user_command) diff --git a/nouri/main.py b/nouri/main.py new file mode 100644 index 0000000..411d209 --- /dev/null +++ b/nouri/main.py @@ -0,0 +1,724 @@ +from __future__ import annotations + +import os +import uuid +from collections import defaultdict +from datetime import date, datetime, timedelta +from pathlib import Path + +from flask import ( + Blueprint, + current_app, + flash, + g, + redirect, + render_template, + request, + url_for, +) +from werkzeug.utils import secure_filename + +from .auth import login_required +from .constants import ( + AVAILABILITY_LABELS, + CATEGORIES, + ITEM_KIND_LABELS, + ITEM_KIND_SINGULAR_LABELS, +) +from .db import get_db + + +main_bp = Blueprint("main", __name__) + +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} + + +def get_dayparts() -> list: + return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() + + +def parse_week_start(raw: str | None) -> date: + if raw: + try: + parsed = datetime.strptime(raw, "%Y-%m-%d").date() + return parsed - timedelta(days=parsed.weekday()) + except ValueError: + pass + today = date.today() + return today - timedelta(days=today.weekday()) + + +def allowed_file(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + + +def save_photo(upload, current_filename: str | None = None) -> str | None: + if not upload or not upload.filename: + return current_filename + + if not allowed_file(upload.filename): + raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") + + original_name = secure_filename(upload.filename) + extension = original_name.rsplit(".", 1)[1].lower() + filename = f"{uuid.uuid4().hex}.{extension}" + destination = Path(current_app.config["UPLOAD_FOLDER"]) / filename + upload.save(destination) + + if current_filename: + old_path = Path(current_app.config["UPLOAD_FOLDER"]) / current_filename + if old_path.exists(): + old_path.unlink() + + return filename + + +def get_item(item_id: int): + item = get_db().execute( + """ + SELECT * + FROM items + WHERE id = ? + """, + (item_id,), + ).fetchone() + if item is None: + raise ValueError("Der Eintrag wurde nicht gefunden.") + return item + + +def get_item_daypart_ids(item_id: int) -> list[int]: + rows = get_db().execute( + "SELECT daypart_id FROM item_dayparts WHERE item_id = ?", + (item_id,), + ).fetchall() + return [row["daypart_id"] for row in rows] + + +def get_meal_component_ids(meal_id: int) -> list[int]: + rows = get_db().execute( + "SELECT food_item_id FROM meal_components WHERE meal_item_id = ?", + (meal_id,), + ).fetchall() + return [row["food_item_id"] for row in rows] + + +def attach_dayparts(items: list) -> list[dict]: + if not items: + return [] + + database = get_db() + ids = [item["id"] for item in items] + placeholders = ",".join("?" for _ in ids) + rows = database.execute( + f""" + SELECT item_dayparts.item_id, dayparts.name + FROM item_dayparts + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE item_dayparts.item_id IN ({placeholders}) + ORDER BY dayparts.sort_order + """, + ids, + ).fetchall() + grouped = defaultdict(list) + for row in rows: + grouped[row["item_id"]].append(row["name"]) + + enriched = [] + for item in items: + entry = dict(item) + entry["dayparts"] = grouped.get(item["id"], []) + enriched.append(entry) + return enriched + + +def attach_components(items: list[dict]) -> list[dict]: + meal_ids = [item["id"] for item in items if item["kind"] == "meal"] + if not meal_ids: + return items + + placeholders = ",".join("?" for _ in meal_ids) + rows = get_db().execute( + f""" + SELECT meal_components.meal_item_id, items.name + FROM meal_components + JOIN items ON items.id = meal_components.food_item_id + WHERE meal_components.meal_item_id IN ({placeholders}) + ORDER BY LOWER(items.name) + """, + meal_ids, + ).fetchall() + grouped = defaultdict(list) + for row in rows: + grouped[row["meal_item_id"]].append(row["name"]) + + for item in items: + item["components"] = grouped.get(item["id"], []) + return items + + +def fetch_items(kind: str | None = None, availability: str | None = None, include_archived: bool = False): + database = get_db() + conditions = [] + params = [] + + if kind: + conditions.append("kind = ?") + params.append(kind) + if availability: + conditions.append("availability_state = ?") + params.append(availability) + elif not include_archived: + conditions.append("availability_state != 'archived'") + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + rows = database.execute( + f""" + SELECT items.*, + EXISTS( + SELECT 1 + FROM shopping_entries + WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 + ) AS is_on_shopping_list + FROM items + {where} + ORDER BY LOWER(name) + """ + , params).fetchall() + return attach_components(attach_dayparts(rows)) + + +def fetch_food_options(): + return fetch_items(kind="food", include_archived=False) + + +def add_to_shopping_list(item_id: int, user_id: int) -> bool: + database = get_db() + existing = database.execute( + """ + SELECT id FROM shopping_entries + WHERE item_id = ? AND is_checked = 0 + """, + (item_id,), + ).fetchone() + if existing: + return False + + database.execute( + """ + INSERT INTO shopping_entries (item_id, added_by) + VALUES (?, ?) + """, + (item_id, user_id), + ) + database.commit() + return True + + +def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: + database = get_db() + database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,)) + for daypart_id in daypart_ids: + database.execute( + "INSERT INTO item_dayparts (item_id, daypart_id) VALUES (?, ?)", + (item_id, daypart_id), + ) + + +def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: + database = get_db() + database.execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,)) + for food_id in food_ids: + database.execute( + """ + INSERT INTO meal_components (meal_item_id, food_item_id) + VALUES (?, ?) + """, + (meal_id, food_id), + ) + + +def fetch_shopping_entries(): + rows = get_db().execute( + """ + SELECT shopping_entries.*, + items.name AS item_name, + items.kind AS item_kind, + items.photo_filename, + items.availability_state, + users.display_name, + users.username + FROM shopping_entries + JOIN items ON items.id = shopping_entries.item_id + LEFT JOIN users ON users.id = shopping_entries.added_by + WHERE shopping_entries.is_checked = 0 + ORDER BY shopping_entries.added_at DESC + """ + ).fetchall() + return rows + + +def fetch_archive_items(): + return fetch_items(availability="archived", include_archived=True) + + +def planner_entries_for_week(week_start: date): + week_end = week_start + timedelta(days=6) + rows = get_db().execute( + """ + SELECT plan_entries.*, + items.name AS item_name, + items.kind AS item_kind, + items.photo_filename, + dayparts.name AS daypart_name, + dayparts.slug AS daypart_slug + FROM plan_entries + JOIN items ON items.id = plan_entries.item_id + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_date BETWEEN ? AND ? + ORDER BY plan_date, dayparts.sort_order, items.name + """, + (week_start.isoformat(), week_end.isoformat()), + ).fetchall() + grouped = defaultdict(list) + for row in rows: + grouped[(row["plan_date"], row["daypart_id"])].append(row) + return grouped + + +@main_bp.get("/") +@login_required +def dashboard(): + database = get_db() + today = date.today().isoformat() + home_count = database.execute( + "SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'" + ).fetchone()["count"] + shopping_count = database.execute( + "SELECT COUNT(*) AS count FROM shopping_entries WHERE is_checked = 0" + ).fetchone()["count"] + archive_count = database.execute( + "SELECT COUNT(*) AS count FROM items WHERE availability_state = 'archived'" + ).fetchone()["count"] + today_entries = database.execute( + """ + SELECT plan_entries.id, + items.name AS item_name, + items.kind AS item_kind, + dayparts.name AS daypart_name + FROM plan_entries + JOIN items ON items.id = plan_entries.item_id + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date = ? + ORDER BY dayparts.sort_order, items.name + """, + (today,), + ).fetchall() + home_items = fetch_items(availability="home") + return render_template( + "dashboard.html", + home_count=home_count, + shopping_count=shopping_count, + archive_count=archive_count, + today_entries=today_entries, + home_items=home_items[:8], + today=today, + ) + + +@main_bp.route("/items/") +@login_required +def item_list(kind: str): + if kind not in ITEM_KIND_LABELS: + return redirect(url_for("main.dashboard")) + items = fetch_items(kind=kind) + return render_template( + "items/list.html", + kind=kind, + items=items, + availability_labels=AVAILABILITY_LABELS, + ) + + +@main_bp.route("/items//new", methods=("GET", "POST")) +@login_required +def item_create(kind: str): + if kind not in ITEM_KIND_LABELS: + return redirect(url_for("main.dashboard")) + + database = get_db() + dayparts = get_dayparts() + foods = fetch_food_options() + form_data = { + "name": "", + "category": "", + "note": "", + "daypart_ids": [], + "component_ids": [], + } + + if request.method == "POST": + name = request.form.get("name", "").strip() + category = request.form.get("category", "").strip() + note = request.form.get("note", "").strip() + daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")] + component_ids = [int(value) for value in request.form.getlist("component_ids")] + form_data.update( + { + "name": name, + "category": category, + "note": note, + "daypart_ids": daypart_ids, + "component_ids": component_ids, + } + ) + + error = None + if not name: + error = "Bitte einen Namen eintragen." + elif kind == "meal" and not component_ids: + error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen." + + photo_filename = None + if error is None: + try: + photo_filename = save_photo(request.files.get("photo")) + except ValueError as exc: + error = str(exc) + + if error is None: + cursor = database.execute( + """ + INSERT INTO items (kind, name, category, note, photo_filename, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (kind, name, category, note, photo_filename, g.user["id"], g.user["id"]), + ) + item_id = cursor.lastrowid + sync_item_dayparts(item_id, daypart_ids) + if kind == "meal": + sync_meal_components(item_id, component_ids) + database.commit() + flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success") + return redirect(url_for("main.item_list", kind=kind)) + + flash(error, "error") + + return render_template( + "items/form.html", + kind=kind, + item=None, + dayparts=dayparts, + foods=foods, + categories=CATEGORIES, + form_data=form_data, + ) + + +@main_bp.route("/items//edit", methods=("GET", "POST")) +@login_required +def item_edit(item_id: int): + database = get_db() + item = get_item(item_id) + kind = item["kind"] + dayparts = get_dayparts() + foods = fetch_food_options() + form_data = { + "name": item["name"], + "category": item["category"] or "", + "note": item["note"] or "", + "daypart_ids": get_item_daypart_ids(item_id), + "component_ids": get_meal_component_ids(item_id) if kind == "meal" else [], + } + + if request.method == "POST": + name = request.form.get("name", "").strip() + category = request.form.get("category", "").strip() + note = request.form.get("note", "").strip() + daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")] + component_ids = [int(value) for value in request.form.getlist("component_ids")] + form_data.update( + { + "name": name, + "category": category, + "note": note, + "daypart_ids": daypart_ids, + "component_ids": component_ids, + } + ) + + error = None + if not name: + error = "Bitte einen Namen eintragen." + elif kind == "meal" and not component_ids: + error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen." + + photo_filename = item["photo_filename"] + if error is None: + try: + photo_filename = save_photo(request.files.get("photo"), current_filename=item["photo_filename"]) + except ValueError as exc: + error = str(exc) + + if error is None: + database.execute( + """ + UPDATE items + SET name = ?, category = ?, note = ?, photo_filename = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (name, category, note, photo_filename, g.user["id"], item_id), + ) + sync_item_dayparts(item_id, daypart_ids) + if kind == "meal": + sync_meal_components(item_id, component_ids) + database.commit() + flash("Der Eintrag wurde aktualisiert.", "success") + return redirect(url_for("main.item_list", kind=kind)) + + flash(error, "error") + + return render_template( + "items/form.html", + kind=kind, + item=item, + dayparts=dayparts, + foods=foods, + categories=CATEGORIES, + form_data=form_data, + ) + + +@main_bp.post("/items//shopping") +@login_required +def item_add_to_shopping(item_id: int): + item = get_item(item_id) + added = add_to_shopping_list(item_id, g.user["id"]) + if added: + flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") + else: + flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + return redirect(request.referrer or url_for("main.shopping_list")) + + +@main_bp.post("/items//set-home") +@login_required +def item_set_home(item_id: int): + item = get_item(item_id) + database = get_db() + database.execute( + """ + UPDATE items + SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item_id), + ) + database.commit() + flash(f"{item['name']} ist jetzt unter Zuhause sichtbar.", "success") + return redirect(request.referrer or url_for("main.home_view")) + + +@main_bp.post("/items//archive") +@login_required +def item_archive(item_id: int): + item = get_item(item_id) + database = get_db() + database.execute( + """ + UPDATE items + SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item_id), + ) + database.commit() + flash(f"{item['name']} liegt jetzt im Archiv und bleibt spaeter leicht wiederfindbar.", "info") + return redirect(request.referrer or url_for("main.archive_view")) + + +@main_bp.post("/items//restore") +@login_required +def item_restore(item_id: int): + item = get_item(item_id) + database = get_db() + database.execute( + """ + UPDATE items + SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item_id), + ) + database.commit() + flash(f"{item['name']} ist wieder in der aktiven Liste.", "success") + return redirect(request.referrer or url_for("main.archive_view")) + + +@main_bp.route("/shopping", methods=("GET", "POST")) +@login_required +def shopping_list(): + database = get_db() + + if request.method == "POST": + selected_item_id = request.form.get("item_id", "").strip() + if not selected_item_id: + flash("Bitte zuerst etwas auswaehlen.", "error") + else: + item = get_item(int(selected_item_id)) + added = add_to_shopping_list(item["id"], g.user["id"]) + if added: + flash(f"{item['name']} wurde auf die Einkaufsliste gesetzt.", "success") + else: + flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + return redirect(url_for("main.shopping_list")) + + entries = fetch_shopping_entries() + addable_items = database.execute( + """ + SELECT items.id, items.name, items.kind, items.availability_state + FROM items + WHERE items.availability_state != 'archived' + AND NOT EXISTS ( + SELECT 1 FROM shopping_entries + WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 + ) + ORDER BY CASE items.availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(items.name) + """ + ).fetchall() + return render_template("shopping/list.html", entries=entries, addable_items=addable_items) + + +@main_bp.post("/shopping//check") +@login_required +def shopping_check(entry_id: int): + database = get_db() + entry = database.execute( + "SELECT * FROM shopping_entries WHERE id = ?", + (entry_id,), + ).fetchone() + if entry is None: + flash("Der Einkaufseintrag wurde nicht gefunden.", "error") + return redirect(url_for("main.shopping_list")) + + item = get_item(entry["item_id"]) + database.execute( + """ + UPDATE shopping_entries + SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? + WHERE id = ? + """, + (g.user["id"], entry_id), + ) + database.execute( + """ + UPDATE items + SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item["id"]), + ) + database.commit() + flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") + return redirect(url_for("main.shopping_list")) + + +@main_bp.post("/shopping//remove") +@login_required +def shopping_remove(entry_id: int): + database = get_db() + database.execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,)) + database.commit() + flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info") + return redirect(url_for("main.shopping_list")) + + +@main_bp.get("/home") +@login_required +def home_view(): + items = fetch_items(availability="home") + grouped = defaultdict(list) + for item in items: + key = item["dayparts"][0] if item["dayparts"] else "Ohne feste Tageszeit" + grouped[key].append(item) + return render_template("home/list.html", grouped=grouped) + + +@main_bp.get("/archive") +@login_required +def archive_view(): + items = fetch_archive_items() + return render_template("archive/list.html", items=items) + + +@main_bp.route("/planner", methods=("GET", "POST")) +@login_required +def planner(): + database = get_db() + week_start = parse_week_start(request.values.get("week")) + + if request.method == "POST": + try: + selected_date = datetime.strptime(request.form.get("plan_date", ""), "%Y-%m-%d").date() + except ValueError: + selected_date = None + + item_id = request.form.get("item_id", "").strip() + daypart_id = request.form.get("daypart_id", "").strip() + note = request.form.get("note", "").strip() + + error = None + if selected_date is None: + error = "Bitte einen gueltigen Tag auswaehlen." + elif not item_id: + error = "Bitte etwas fuer den Plan waehlen." + elif not daypart_id: + error = "Bitte eine Tageszeit waehlen." + + if error is None: + database.execute( + """ + INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by) + VALUES (?, ?, ?, ?, ?) + """, + (selected_date.isoformat(), int(daypart_id), int(item_id), note, g.user["id"]), + ) + database.commit() + flash("Der Eintrag wurde in den Wochenplan gelegt.", "success") + else: + flash(error, "error") + + return redirect(url_for("main.planner", week=week_start.isoformat())) + + days = [week_start + timedelta(days=index) for index in range(7)] + dayparts = get_dayparts() + entries = planner_entries_for_week(week_start) + selectable_items = database.execute( + """ + SELECT id, name, kind, availability_state + FROM items + WHERE availability_state != 'archived' + ORDER BY CASE availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(name) + """ + ).fetchall() + return render_template( + "planner/week.html", + week_start=week_start, + prev_week=week_start - timedelta(days=7), + next_week=week_start + timedelta(days=7), + days=days, + dayparts=dayparts, + entries=entries, + selectable_items=selectable_items, + ) + + +@main_bp.post("/planner//remove") +@login_required +def planner_remove(entry_id: int): + database = get_db() + week = request.args.get("week") + database.execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,)) + database.commit() + flash("Der Planeintrag wurde entfernt.", "info") + return redirect(url_for("main.planner", week=week)) diff --git a/nouri/schema.sql b/nouri/schema.sql new file mode 100644 index 0000000..cfd359b --- /dev/null +++ b/nouri/schema.sql @@ -0,0 +1,78 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS dayparts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), + name TEXT NOT NULL, + category TEXT, + note TEXT, + photo_filename TEXT, + availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')), + created_by INTEGER, + updated_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS item_dayparts ( + item_id INTEGER NOT NULL, + daypart_id INTEGER NOT NULL, + PRIMARY KEY (item_id, daypart_id), + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS meal_components ( + meal_item_id INTEGER NOT NULL, + food_item_id INTEGER NOT NULL, + PRIMARY KEY (meal_item_id, food_item_id), + FOREIGN KEY (meal_item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (food_item_id) REFERENCES items(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS shopping_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + added_by INTEGER, + checked_by INTEGER, + is_checked INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + checked_at TEXT, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item +ON shopping_entries (item_id) +WHERE is_checked = 0; + +CREATE TABLE IF NOT EXISTS plan_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_date TEXT NOT NULL, + daypart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + note TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css new file mode 100644 index 0000000..20f421c --- /dev/null +++ b/nouri/static/css/styles.css @@ -0,0 +1,643 @@ +:root { + color-scheme: light; + --bg: #f5f1e8; + --bg-elevated: rgba(255, 252, 246, 0.9); + --surface: #fffaf2; + --surface-strong: #ffffff; + --surface-soft: #efe7d7; + --line: rgba(74, 78, 72, 0.12); + --text: #243028; + --muted: #66736a; + --accent: #6a8b78; + --accent-strong: #476654; + --accent-soft: rgba(106, 139, 120, 0.12); + --warning-soft: rgba(196, 136, 92, 0.16); + --shadow: 0 18px 40px rgba(44, 56, 46, 0.08); + --radius: 20px; + --font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; + --font-heading: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #1b211d; + --bg-elevated: rgba(27, 33, 29, 0.92); + --surface: #222925; + --surface-strong: #29312c; + --surface-soft: #323b36; + --line: rgba(224, 229, 223, 0.1); + --text: #edf1ea; + --muted: #b6c0b6; + --accent: #9dbf9d; + --accent-strong: #b8d5b1; + --accent-soft: rgba(157, 191, 157, 0.15); + --warning-soft: rgba(201, 148, 108, 0.22); + --shadow: 0 18px 40px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-body); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(178, 197, 168, 0.28), transparent 26rem), + radial-gradient(circle at top right, rgba(238, 210, 177, 0.25), transparent 28rem), + linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 84%, #000 16%)); +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.8rem 1.1rem; + border: 1px solid transparent; + border-radius: 999px; + background: var(--accent); + color: white; + cursor: pointer; + transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; +} + +button:hover, +.button:hover { + transform: translateY(-1px); + background: var(--accent-strong); +} + +.button.secondary, +button.secondary, +.ghost-button { + background: transparent; + color: var(--text); + border-color: var(--line); +} + +.button.secondary:hover, +button.secondary:hover, +.ghost-button:hover { + background: var(--accent-soft); +} + +.page-shell { + width: min(1200px, calc(100% - 2rem)); + margin: 1rem auto 2rem; +} + +.site-header { + position: sticky; + top: 1rem; + z-index: 10; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 1rem; + align-items: center; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + background: var(--bg-elevated); + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; +} + +.brand strong, +h1, h2, h3, .planner-label { + font-family: var(--font-heading); + letter-spacing: -0.02em; +} + +.brand small { + display: block; + color: var(--muted); + margin-top: 0.12rem; +} + +.brand-mark { + width: 2.5rem; + height: 2.5rem; + display: grid; + place-items: center; + border-radius: 0.9rem; + background: linear-gradient(135deg, var(--accent), #d1b48f); + color: white; + font-weight: 700; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; +} + +.site-nav a { + padding: 0.55rem 0.85rem; + border-radius: 999px; + color: var(--muted); +} + +.site-nav a.active, +.site-nav a:hover { + background: var(--accent-soft); + color: var(--text); +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.content { + display: grid; + gap: 1.25rem; +} + +.hero, +.page-intro, +.panel, +.auth-card, +.stat-card, +.item-card, +.list-row, +.planner-entry { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.hero, +.page-intro, +.panel, +.auth-card { + padding: 1.35rem; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: end; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 45%), + linear-gradient(180deg, var(--surface), var(--surface-strong)); +} + +.eyebrow { + margin: 0 0 0.45rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.78rem; + color: var(--muted); +} + +h1, h2, h3 { + margin: 0; +} + +h1 { + font-size: clamp(2rem, 3vw, 3rem); + line-height: 1.06; +} + +h2 { + font-size: 1.45rem; +} + +h3 { + font-size: 1.15rem; +} + +.lead, +.muted, +.empty-state, +.empty-slot, +.planner-entry p, +.simple-list span, +.simple-list small { + color: var(--muted); +} + +.lead { + max-width: 60ch; + line-height: 1.6; +} + +.stats-grid, +.two-column, +.card-grid, +.mini-card-grid { + display: grid; + gap: 1rem; +} + +.stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.two-column { + grid-template-columns: 1.1fr 0.9fr; +} + +.stat-card { + padding: 1.15rem 1.2rem; +} + +.stat-card span { + display: block; + color: var(--muted); + margin-bottom: 0.5rem; +} + +.stat-card strong { + display: block; + font-size: 2rem; + font-family: var(--font-heading); +} + +.panel-head, +.page-intro, +.item-topline, +.row-actions, +.hero-actions, +.form-actions, +.week-nav { + display: flex; + gap: 0.85rem; + justify-content: space-between; + align-items: center; +} + +.simple-list, +.stack-list { + list-style: none; + padding: 0; + margin: 0; +} + +.simple-list li, +.list-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--line); +} + +.simple-list li:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.mini-card-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.mini-card { + border-radius: 18px; + background: var(--surface-strong); + border: 1px solid var(--line); + padding: 1rem; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.75rem; +} + +.chip, +.status-pill { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--text); + font-size: 0.9rem; +} + +.status-home { + background: rgba(96, 147, 114, 0.18); +} + +.status-archived { + background: var(--warning-soft); +} + +.status-idea { + background: rgba(130, 146, 151, 0.16); +} + +.card-grid { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.item-card { + display: grid; + grid-template-columns: 112px 1fr; + gap: 1rem; + padding: 1rem; +} + +.item-card.compact { + grid-template-columns: 84px 1fr; +} + +.item-media { + aspect-ratio: 1; + overflow: hidden; + border-radius: 18px; + background: var(--surface-soft); +} + +.item-media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.placeholder-tile { + width: 100%; + height: 100%; + display: grid; + place-items: center; + font-size: 2rem; + font-family: var(--font-heading); + color: var(--accent-strong); +} + +.item-body { + min-width: 0; +} + +.item-body p { + line-height: 1.55; +} + +.item-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.auth-shell { + min-height: calc(100vh - 10rem); + display: grid; + place-items: center; +} + +.auth-card { + width: min(560px, 100%); +} + +.stack-form { + display: grid; + gap: 1rem; +} + +.stack-form label, +.planner-form label { + display: grid; + gap: 0.5rem; + color: var(--muted); +} + +input[type="text"], +input[type="password"], +input[type="date"], +input[type="file"], +select, +textarea { + width: 100%; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + background: var(--surface-strong); + color: var(--text); +} + +fieldset { + margin: 0; + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); +} + +legend { + padding: 0 0.4rem; + color: var(--muted); +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.6rem; +} + +.check-option { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.75rem 0.9rem; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.inline-photo img { + width: min(220px, 100%); + border-radius: 18px; + border: 1px solid var(--line); +} + +.compact-form-panel { + padding: 1rem 1.1rem; +} + +.inline-form, +.planner-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; + align-items: end; +} + +.planner-form .wide { + grid-column: span 2; +} + +.list-row { + padding: 1rem 1.1rem; +} + +.row-actions { + justify-content: end; +} + +.stack-sections { + display: grid; + gap: 1rem; +} + +.planner-grid { + display: grid; + gap: 1rem; +} + +.planner-row { + display: grid; + grid-template-columns: 180px repeat(7, minmax(0, 1fr)); + gap: 0.75rem; + align-items: start; +} + +.planner-label { + padding: 1rem; + border-radius: 18px; + background: var(--surface-soft); + border: 1px solid var(--line); +} + +.planner-cell { + min-height: 150px; + padding: 0.8rem; + border-radius: 18px; + background: var(--surface); + border: 1px solid var(--line); +} + +.planner-date { + margin-bottom: 0.7rem; + font-size: 0.9rem; + color: var(--muted); +} + +.planner-entry-stack { + display: grid; + gap: 0.55rem; +} + +.planner-entry { + padding: 0.75rem; + border-radius: 16px; +} + +.flash-stack { + display: grid; + gap: 0.7rem; +} + +.flash { + padding: 0.95rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.flash-success { + background: rgba(111, 161, 122, 0.18); +} + +.flash-error { + background: rgba(195, 111, 98, 0.18); +} + +.flash-info { + background: rgba(125, 150, 164, 0.18); +} + +.theme-toggle { + min-width: 5rem; +} + +@media (max-width: 980px) { + .site-header, + .hero, + .page-intro, + .panel-head { + grid-template-columns: 1fr; + flex-direction: column; + align-items: start; + } + + .site-header { + position: static; + } + + .stats-grid, + .two-column, + .planner-row, + .inline-form, + .planner-form { + grid-template-columns: 1fr; + } + + .planner-form .wide { + grid-column: auto; + } + + .planner-label { + position: sticky; + left: 0; + } +} + +@media (max-width: 720px) { + .page-shell { + width: min(100% - 1rem, 100%); + } + + .site-header { + padding: 1rem; + } + + .site-nav, + .header-actions, + .item-card, + .list-row, + .row-actions { + justify-content: start; + } + + .item-card { + grid-template-columns: 1fr; + } +} diff --git a/nouri/static/js/theme.js b/nouri/static/js/theme.js new file mode 100644 index 0000000..477b1d4 --- /dev/null +++ b/nouri/static/js/theme.js @@ -0,0 +1,32 @@ +(() => { + const root = document.documentElement; + const storageKey = "nouri-theme"; + const toggle = () => document.querySelector("[data-theme-toggle]"); + + const applyTheme = (theme) => { + const resolved = theme || localStorage.getItem(storageKey) || "auto"; + const finalTheme = + resolved === "auto" + ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + : resolved; + root.dataset.theme = finalTheme; + + const button = toggle(); + if (button) { + button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel"; + } + }; + + document.addEventListener("DOMContentLoaded", () => { + applyTheme(); + const button = toggle(); + if (!button) return; + + button.addEventListener("click", () => { + const current = root.dataset.theme === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + localStorage.setItem(storageKey, next); + applyTheme(next); + }); + }); +})(); diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html new file mode 100644 index 0000000..df4c0ee --- /dev/null +++ b/nouri/templates/archive/list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Archiv | Nouri{% endblock %} +{% block content %} +
+
+

Archiv

+

Fruehere Ideen bleiben greifbar

+

Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.

+
+
+ +{% if items %} +
+ {% for item in items %} +
+
+ {% if item.photo_filename %} + {{ item.name }} + {% else %} +
{{ item.name[:1] }}
+ {% endif %} +
+
+

{{ item.name }}

+

{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}

+ {% if item.dayparts %} +
+ {% for daypart in item.dayparts %} + {{ daypart }} + {% endfor %} +
+ {% endif %} + {% if item.components %} +

Mit: {{ item.components|join(', ') }}

+ {% endif %} + {% if item.note %} +

{{ item.note }}

+ {% endif %} +
+
+
+ {{ csrf_input() }} + +
+
+ {{ csrf_input() }} + +
+
+
+ {% endfor %} +
+{% else %} +
+

Das Archiv ist noch leer

+

Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.

+
+{% endif %} +{% endblock %} diff --git a/nouri/templates/auth/login.html b/nouri/templates/auth/login.html new file mode 100644 index 0000000..7e68658 --- /dev/null +++ b/nouri/templates/auth/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Anmelden | Nouri{% endblock %} +{% block content %} +
+
+

Willkommen zurueck

+

Ruhig wieder einsteigen

+

Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.

+ +
+ {{ csrf_input() }} + + + +
+
+
+{% endblock %} diff --git a/nouri/templates/auth/setup.html b/nouri/templates/auth/setup.html new file mode 100644 index 0000000..0c350b5 --- /dev/null +++ b/nouri/templates/auth/setup.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Erster Start | Nouri{% endblock %} +{% block content %} +
+
+

Erster Start

+

Den ersten Haushalt-Zugang anlegen

+

Danach koennt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.

+ +
+ {{ csrf_input() }} + + + + + +
+
+
+{% endblock %} diff --git a/nouri/templates/base.html b/nouri/templates/base.html new file mode 100644 index 0000000..3007228 --- /dev/null +++ b/nouri/templates/base.html @@ -0,0 +1,55 @@ + + + + + + {% block title %}Nouri{% endblock %} + + + + +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ + diff --git a/nouri/templates/dashboard.html b/nouri/templates/dashboard.html new file mode 100644 index 0000000..fff1211 --- /dev/null +++ b/nouri/templates/dashboard.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block title %}Heute | Nouri{% endblock %} +{% block content %} +
+
+

Heute

+

Ein ruhiger Blick auf das, was gerade hilft

+

Du siehst auf einen Blick, was zuhause da ist, was noch eingekauft werden soll und was heute schon eingeplant ist.

+
+ +
+ +
+
+ Zuhause + {{ home_count }} + sichtbare Eintraege +
+
+ Einkaufsliste + {{ shopping_count }} + offene Besorgungen +
+
+ Archiv + {{ archive_count }} + wiederverwendbare Erinnerungen +
+
+ +
+
+
+

Heute im Plan

+ Wochenplan oeffnen +
+ {% if today_entries %} +
    + {% for entry in today_entries %} +
  • + {{ entry.daypart_name }} + {{ entry.item_name }} +
  • + {% endfor %} +
+ {% else %} +

Fuer heute ist noch nichts fest eingeplant. Das ist vollkommen okay.

+ {% endif %} +
+ +
+
+

Kurz griffbereit

+ Alles unter Zuhause +
+ {% if home_items %} +
+ {% for item in home_items %} +
+
+ {{ item.name }} + {{ item_kind_labels[item.kind] }} + {% if item.dayparts %} +
+ {% for daypart in item.dayparts %} + {{ daypart }} + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Sobald etwas eingekauft oder manuell auf Zuhause gesetzt wurde, erscheint es hier.

+ {% endif %} +
+
+{% endblock %} diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html new file mode 100644 index 0000000..bab82ea --- /dev/null +++ b/nouri/templates/home/list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Zuhause | Nouri{% endblock %} +{% block content %} +
+
+

Zuhause

+

Was aktuell da ist

+

Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.

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

{{ title }}

+ {{ items|length }} Eintraege +
+
+ {% for item in items %} +
+
+ {% if item.photo_filename %} + {{ item.name }} + {% else %} +
{{ item.name[:1] }}
+ {% endif %} +
+
+

{{ item.name }}

+

{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}

+ {% if item.components %} +

Mit: {{ item.components|join(', ') }}

+ {% endif %} +
+
+
+ {{ csrf_input() }} + +
+
+ {{ csrf_input() }} + +
+
+
+ {% endfor %} +
+
+ {% endfor %} +
+{% else %} +
+

Noch nichts unter Zuhause

+

Ein Einkaufseintrag wird nach dem Abhaken automatisch hier sichtbar.

+
+{% endif %} +{% endblock %} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html new file mode 100644 index 0000000..ac7eb97 --- /dev/null +++ b/nouri/templates/items/form.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}{% if item %}Bearbeiten{% else %}Neu{% endif %} | Nouri{% endblock %} +{% block content %} +
+
+

{{ item_kind_labels[kind] }}

+

{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}

+

Nur das Nötigste: Name, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.

+
+
+ +
+
+ {{ csrf_input() }} + + + + + + + + + {% if item and item.photo_filename %} +
+ {{ item.name }} +
+ {% endif %} + +
+ Passende Tageszeiten +
+ {% for daypart in dayparts %} + + {% endfor %} +
+
+ + {% if kind == 'meal' %} +
+ Bestandteile der Mahlzeitenidee +
+ {% for food in foods %} + + {% endfor %} +
+
+ {% endif %} + +
+ + Zurueck +
+
+
+{% endblock %} diff --git a/nouri/templates/items/list.html b/nouri/templates/items/list.html new file mode 100644 index 0000000..1595462 --- /dev/null +++ b/nouri/templates/items/list.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}{{ item_kind_labels[kind] }} | Nouri{% endblock %} +{% block content %} +
+
+

{{ item_kind_labels[kind] }}

+

{{ item_kind_labels[kind] }}

+

Schnell gepflegte Eintraege mit Foto, Tageszeiten und einem ruhigen Status zwischen Idee, Zuhause und Archiv.

+
+ Neu anlegen +
+ +{% if items %} +
+ {% for item in items %} +
+
+ {% if item.photo_filename %} + {{ item.name }} + {% else %} +
{{ item.name[:1] }}
+ {% endif %} +
+
+
+

{{ item.name }}

+ {{ availability_labels[item.availability_state] }} +
+

+ {% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %} + · + {{ item_kind_labels[item.kind] }} +

+ {% if item.dayparts %} +
+ {% for daypart in item.dayparts %} + {{ daypart }} + {% endfor %} +
+ {% endif %} + {% if item.components %} +

Mit: {{ item.components|join(', ') }}

+ {% endif %} + {% if item.note %} +

{{ item.note }}

+ {% endif %} +
+
+ Bearbeiten +
+ {{ csrf_input() }} + +
+ {% if item.availability_state != 'home' %} +
+ {{ csrf_input() }} + +
+ {% endif %} + {% if item.availability_state != 'archived' %} +
+ {{ csrf_input() }} + +
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+

Noch keine Eintraege

+

Der schnellste Start ist ein erstes vertrautes Lebensmittel oder eine einfache Mahlzeitenidee.

+ Ersten Eintrag anlegen +
+{% endif %} +{% endblock %} diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html new file mode 100644 index 0000000..eff9ca7 --- /dev/null +++ b/nouri/templates/planner/week.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Wochenplan | Nouri{% endblock %} +{% block content %} +
+
+

Wochenplan

+

Struktur fuer die naechsten Tage

+

Der Plan bleibt bewusst leichtgewichtig. Vorhandene Dinge tauchen in der Auswahl zuerst auf.

+
+
+ Vorige Woche + {{ days[0].strftime('%d.%m.') }} bis {{ days[-1].strftime('%d.%m.%Y') }} + Naechste Woche +
+
+ +
+
+ {{ csrf_input() }} + + + + + +
+
+ +
+ {% for daypart in dayparts %} +
+
{{ daypart.name }}
+ {% for day in days %} +
+
{{ day.strftime('%a %d.%m.') }}
+ {% set slot_entries = entries.get((day.isoformat(), daypart.id), []) %} + {% if slot_entries %} +
+ {% for entry in slot_entries %} +
+ {{ entry.item_name }} + {{ item_kind_labels[entry.item_kind] }} + {% if entry.note %} +

{{ entry.note }}

+ {% endif %} +
+ {{ csrf_input() }} + +
+
+ {% endfor %} +
+ {% else %} +

frei

+ {% endif %} +
+ {% endfor %} +
+ {% endfor %} +
+{% endblock %} diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html new file mode 100644 index 0000000..aa3f95d --- /dev/null +++ b/nouri/templates/shopping/list.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Einkaufsliste | Nouri{% endblock %} +{% block content %} +
+
+

Einkaufsliste

+

Was noch mitkommen soll

+

Abhaken legt Dinge automatisch unter Zuhause ab. So wird aus der Liste direkt sichtbarer Vorrat.

+
+
+ +
+
+ {{ csrf_input() }} + + +
+
+ +{% if entries %} +
+ {% for entry in entries %} +
+
+ {{ entry.item_name }} +

{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefuegt von {{ entry.display_name or entry.username }}{% endif %}

+
+
+
+ {{ csrf_input() }} + +
+
+ {{ csrf_input() }} + +
+
+
+ {% endfor %} +
+{% else %} +
+

Die Liste ist gerade frei

+

Eintraege aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufuegen.

+
+{% endif %} +{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa4b129 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.1 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0824112 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,4 @@ +from nouri import create_app + + +app = create_app()