first commit
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.DS_Store
|
||||
|
||||
data/
|
||||
instance/
|
||||
@@ -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.
|
||||
@@ -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/<path:filename>")
|
||||
def uploaded_file(filename: str):
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
|
||||
return app
|
||||
+143
@@ -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'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@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"))
|
||||
@@ -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",
|
||||
}
|
||||
+99
@@ -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)
|
||||
+724
@@ -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/<kind>")
|
||||
@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/<kind>/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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/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/<int:entry_id>/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/<int:entry_id>/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/<int:entry_id>/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))
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Archiv | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Archiv</p>
|
||||
<h1>Fruehere Ideen bleiben greifbar</h1>
|
||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if items %}
|
||||
<section class="card-grid">
|
||||
{% for item in items %}
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<h2>{{ item.name }}</h2>
|
||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.components %}
|
||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||
{% endif %}
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Wieder einkaufen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel empty-panel">
|
||||
<h2>Das Archiv ist noch leer</h2>
|
||||
<p>Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Anmelden | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<p class="eyebrow">Willkommen zurueck</p>
|
||||
<h1>Ruhig wieder einsteigen</h1>
|
||||
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
||||
|
||||
<form method="post" class="stack-form">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Erster Start | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<p class="eyebrow">Erster Start</p>
|
||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||
<p class="lead">Danach koennt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
||||
|
||||
<form method="post" class="stack-form">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" name="password" autocomplete="new-password" required>
|
||||
</label>
|
||||
<label>
|
||||
Passwort wiederholen
|
||||
<input type="password" name="password_repeat" autocomplete="new-password" required>
|
||||
</label>
|
||||
<button type="submit">Zugang anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Nouri{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">N</span>
|
||||
<span>
|
||||
<strong>Nouri</strong>
|
||||
<small>freundliches Essensgedaechtnis</small>
|
||||
</span>
|
||||
</a>
|
||||
{% if g.user %}
|
||||
<nav class="site-nav">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">Heute</a>
|
||||
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}">Lebensmittel</a>
|
||||
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}">Mahlzeiten</a>
|
||||
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">Einkaufsliste</a>
|
||||
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}">Zuhause</a>
|
||||
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">Wochenplan</a>
|
||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}">Archiv</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<section class="flash-stack" aria-label="Hinweise">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Heute | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Heute</p>
|
||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was noch eingekauft werden soll und was heute schon eingeplant ist.</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='food') }}">Lebensmittel anlegen</a>
|
||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<span>Zuhause</span>
|
||||
<strong>{{ home_count }}</strong>
|
||||
<small>sichtbare Eintraege</small>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>Einkaufsliste</span>
|
||||
<strong>{{ shopping_count }}</strong>
|
||||
<small>offene Besorgungen</small>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>Archiv</span>
|
||||
<strong>{{ archive_count }}</strong>
|
||||
<small>wiederverwendbare Erinnerungen</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Heute im Plan</h2>
|
||||
<a href="{{ url_for('main.planner') }}">Wochenplan oeffnen</a>
|
||||
</div>
|
||||
{% if today_entries %}
|
||||
<ul class="simple-list">
|
||||
{% for entry in today_entries %}
|
||||
<li>
|
||||
<strong>{{ entry.daypart_name }}</strong>
|
||||
<span>{{ entry.item_name }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">Fuer heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Kurz griffbereit</h2>
|
||||
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
|
||||
</div>
|
||||
{% if home_items %}
|
||||
<div class="mini-card-grid">
|
||||
{% for item in home_items %}
|
||||
<article class="mini-card">
|
||||
<div class="mini-card-body">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item_kind_labels[item.kind] }}</small>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Sobald etwas eingekauft oder manuell auf Zuhause gesetzt wurde, erscheint es hier.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zuhause | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Zuhause</p>
|
||||
<h1>Was aktuell da ist</h1>
|
||||
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if grouped %}
|
||||
<section class="stack-sections">
|
||||
{% for title, items in grouped.items() %}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>{{ title }}</h2>
|
||||
<span>{{ items|length }} Eintraege</span>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
{% for item in items %}
|
||||
<article class="item-card compact">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||
{% if item.components %}
|
||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Erneut einkaufen</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel empty-panel">
|
||||
<h2>Noch nichts unter Zuhause</h2>
|
||||
<p>Ein Einkaufseintrag wird nach dem Abhaken automatisch hier sichtbar.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if item %}Bearbeiten{% else %}Neu{% endif %} | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
||||
<p class="lead">Nur das Nötigste: Name, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel form-panel">
|
||||
<form method="post" enctype="multipart/form-data" class="stack-form">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value="{{ form_data.name }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Kategorie
|
||||
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack">
|
||||
<datalist id="category-list">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category }}"></option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Notiz
|
||||
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Foto
|
||||
<input type="file" name="photo" accept="image/png,image/jpeg,image/gif,image/webp">
|
||||
</label>
|
||||
|
||||
{% if item and item.photo_filename %}
|
||||
<div class="inline-photo">
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Passende Tageszeiten</legend>
|
||||
<div class="checkbox-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<label class="check-option">
|
||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||
<span>{{ daypart.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{% if kind == 'meal' %}
|
||||
<fieldset>
|
||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||
<div class="checkbox-grid">
|
||||
{% for food in foods %}
|
||||
<label class="check-option">
|
||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||
<span>{{ food.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurueck</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ item_kind_labels[kind] }} | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<h1>{{ item_kind_labels[kind] }}</h1>
|
||||
<p class="lead">Schnell gepflegte Eintraege mit Foto, Tageszeiten und einem ruhigen Status zwischen Idee, Zuhause und Archiv.</p>
|
||||
</div>
|
||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||
</section>
|
||||
|
||||
{% if items %}
|
||||
<section class="card-grid">
|
||||
{% for item in items %}
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<div class="item-topline">
|
||||
<h2>{{ item.name }}</h2>
|
||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||
</div>
|
||||
<p class="muted">
|
||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
||||
·
|
||||
{{ item_kind_labels[item.kind] }}
|
||||
</p>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.components %}
|
||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||
{% endif %}
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Auf Einkaufsliste</button>
|
||||
</form>
|
||||
{% if item.availability_state != 'home' %}
|
||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if item.availability_state != 'archived' %}
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Ins Archiv</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel empty-panel">
|
||||
<h2>Noch keine Eintraege</h2>
|
||||
<p>Der schnellste Start ist ein erstes vertrautes Lebensmittel oder eine einfache Mahlzeitenidee.</p>
|
||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Ersten Eintrag anlegen</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Wochenplan | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Wochenplan</p>
|
||||
<h1>Struktur fuer die naechsten Tage</h1>
|
||||
<p class="lead">Der Plan bleibt bewusst leichtgewichtig. Vorhandene Dinge tauchen in der Auswahl zuerst auf.</p>
|
||||
</div>
|
||||
<div class="week-nav">
|
||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||
<span>{{ days[0].strftime('%d.%m.') }} bis {{ days[-1].strftime('%d.%m.%Y') }}</span>
|
||||
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Naechste Woche</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-form-panel">
|
||||
<form method="post" class="planner-form">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Tag
|
||||
<input type="date" name="plan_date" value="{{ days[0].isoformat() }}">
|
||||
</label>
|
||||
<label>
|
||||
Tageszeit
|
||||
<select name="daypart_id">
|
||||
{% for daypart in dayparts %}
|
||||
<option value="{{ daypart.id }}">{{ daypart.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Eintrag
|
||||
<select name="item_id">
|
||||
<option value="">Etwas fuer den Plan waehlen</option>
|
||||
{% for item in selectable_items %}
|
||||
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Notiz
|
||||
<input type="text" name="note" placeholder="Optional, z. B. zuerst einkaufen">
|
||||
</label>
|
||||
<button type="submit">In den Plan legen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="planner-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<div class="planner-row">
|
||||
<div class="planner-label">{{ daypart.name }}</div>
|
||||
{% for day in days %}
|
||||
<div class="planner-cell">
|
||||
<div class="planner-date">{{ day.strftime('%a %d.%m.') }}</div>
|
||||
{% set slot_entries = entries.get((day.isoformat(), daypart.id), []) %}
|
||||
{% if slot_entries %}
|
||||
<div class="planner-entry-stack">
|
||||
{% for entry in slot_entries %}
|
||||
<article class="planner-entry">
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
||||
{% if entry.note %}
|
||||
<p>{{ entry.note }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, week=week_start.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-slot">frei</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Einkaufsliste | Nouri{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Einkaufsliste</p>
|
||||
<h1>Was noch mitkommen soll</h1>
|
||||
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. So wird aus der Liste direkt sichtbarer Vorrat.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-form-panel">
|
||||
<form method="post" class="inline-form">
|
||||
{{ csrf_input() }}
|
||||
<select name="item_id">
|
||||
<option value="">Bestehenden Eintrag hinzufuegen</option>
|
||||
{% for item in addable_items %}
|
||||
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Auf Liste setzen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if entries %}
|
||||
<section class="stack-list">
|
||||
{% for entry in entries %}
|
||||
<article class="list-row">
|
||||
<div>
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<p class="muted">{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefuegt von {{ entry.display_name or entry.username }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Eingekauft</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel empty-panel">
|
||||
<h2>Die Liste ist gerade frei</h2>
|
||||
<p>Eintraege aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufuegen.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
Flask==3.1.1
|
||||
Reference in New Issue
Block a user