diff --git a/CloudronManifest.json b/CloudronManifest.json index b20cd3a..43aa619 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "0.5.1", - "upstreamVersion": "0.5.1", + "version": "0.6.0", + "upstreamVersion": "0.6.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_0.6.0.md b/RELEASE_NOTES_0.6.0.md new file mode 100644 index 0000000..c10358d --- /dev/null +++ b/RELEASE_NOTES_0.6.0.md @@ -0,0 +1,55 @@ +# Nouri 0.6.0 + +Nouri 0.6.0 bringt die App näher an einen stabilen 1.0-Stand. Der Schwerpunkt liegt auf Reife, Alltagstauglichkeit und einem ruhigeren, verlässlicheren Gesamteindruck statt auf großen neuen Kernfunktionen. + +## Highlights + +### Design und Brand + +- Neues Nouri-App-Icon für Header, Favicon und PWA +- Überarbeitete PWA-Icons inklusive zusätzlicher Größen und maskierbarer Variante +- Ruhigeres visuelles Finish bei Karten, Fokuszuständen, Setup und leeren Bereichen +- Mobile Header-Logik bereinigt, damit der obere Bereich auf Smartphones nicht fest mitscrollt + +### Bilder und Performance + +- Bild-Uploads werden jetzt in mehrere sinnvolle Größen abgeleitet +- Listen und Formulare nutzen responsive Bildauslieferung statt immer die volle Originalgröße +- Statische Assets bekommen sauberes Cache-Busting über Versions-URLs +- Uploads und statische Marken-Assets werden vorsichtig browserfreundlich gecacht + +### PWA und iPhone-Nutzung + +- Überarbeitetes Web App Manifest +- Verbesserter Service Worker für eine stabilere App-Shell +- Kleine Offline-Seite für kurze Verbindungsabbrüche +- Bessere Einbindung für Homescreen-Nutzung auf iPhones + +### Backup und Stabilität + +- Admin-Bereich in den Optionen für Backup-Export und Restore +- Backup als ZIP mit App-Daten und Uploads +- Restore mit klarer Bestätigung statt versehentlicher Überschreibung +- Freundlichere Behandlung von zu großen Bild-Uploads +- Robusterer Umgang mit vorhandenen älteren Datenstrukturen + +### Planung und Vorschläge + +- Automatische Mahlzeitenvorschläge robuster aufgebaut +- Bausteinlogik für Protein, Kohlenhydrate und Gemüse verbessert +- Bestehende Kombinationen werden beim Übernehmen besser wiederverwendet +- Suche beim Zusammenstellen von Mahlzeiten zeigt nur die drei passendsten Treffer +- Kategorie-Richtung von „Brot & Getreide“ in Richtung „Kohlenhydrate“ weitergeführt + +## Technische Änderungen + +- Neue Hilfsmodule für Bildverarbeitung und Backup/Restore +- App-Metadaten-Tabelle für robustere Schema-Verwaltung vorbereitet +- Cloudron-Version auf 0.6.0 angehoben +- Pillow als zusätzliche Abhängigkeit für lokale Bildverarbeitung ergänzt + +## Hinweise zum Update + +- Nach dem Update kann ein harter Reload im Browser sinnvoll sein, damit neue CSS-, JS- und PWA-Dateien sicher geladen werden. +- Vor einem Restore empfiehlt sich immer zuerst ein frischer Backup-Export aus der laufenden App. +- Vorhandene Kategorien und ältere Daten werden beim Start weiter normalisiert, statt hart ersetzt zu werden. diff --git a/nouri/__init__.py b/nouri/__init__.py index dedfb8f..9d22d90 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -5,7 +5,7 @@ import secrets from datetime import date, timedelta from pathlib import Path -from flask import Flask, g, send_from_directory +from flask import Flask, flash, g, redirect, request, send_from_directory, url_for from . import db from .admin import admin_bp @@ -24,6 +24,7 @@ from .constants import ( VISIBILITY_LABELS, WEEKDAY_OPTIONS, ) +from .images import ensure_upload_structure, image_sizes, image_srcset, image_url from .main import main_bp @@ -56,7 +57,7 @@ def create_app() -> Flask: db_path = data_dir / "nouri.sqlite3" data_dir.mkdir(parents=True, exist_ok=True) - upload_dir.mkdir(parents=True, exist_ok=True) + ensure_upload_structure(upload_dir) app = Flask(__name__, instance_relative_config=False) app.config.update( @@ -68,7 +69,7 @@ def create_app() -> Flask: PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", - APP_VERSION="0.5.1", + APP_VERSION="0.6.0", VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), @@ -83,6 +84,11 @@ def create_app() -> Flask: @app.context_processor def inject_globals() -> dict[str, object]: + def asset_url(filename: str) -> str: + file_path = root_dir / "nouri" / "static" / filename + version = int(file_path.stat().st_mtime) if file_path.exists() else app.config["APP_VERSION"] + return url_for("static", filename=filename, v=version) + return { "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, @@ -103,18 +109,61 @@ def create_app() -> Flask: "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", + "asset_url": asset_url, + "image_url": lambda filename, variant="md": image_url( + filename, + url_for, + variant, + upload_folder=app.config["UPLOAD_FOLDER"], + ), + "image_srcset": lambda filename: image_srcset( + filename, + url_for, + upload_folder=app.config["UPLOAD_FOLDER"], + ), + "image_sizes": image_sizes, } @app.get("/uploads/") def uploaded_file(filename: str): - return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + response = send_from_directory(app.config["UPLOAD_FOLDER"], filename, max_age=60 * 60 * 24 * 30) + response.headers["Cache-Control"] = "public, max-age=2592000, immutable" + return response @app.get("/app.webmanifest") def webmanifest(): - return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json") + response = send_from_directory( + root_dir / "nouri" / "static" / "pwa", + "app.webmanifest", + mimetype="application/manifest+json", + max_age=60 * 30, + ) + response.headers["Cache-Control"] = "public, max-age=1800" + return response @app.get("/service-worker.js") def service_worker(): - return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript") + response = send_from_directory( + root_dir / "nouri" / "static" / "pwa", + "service-worker.js", + mimetype="application/javascript", + max_age=0, + ) + response.headers["Cache-Control"] = "no-store" + return response + + @app.after_request + def apply_cache_policy(response): + if response.direct_passthrough: + return response + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("text/html"): + response.headers.setdefault("Cache-Control", "no-store, max-age=0") + return response + + @app.errorhandler(413) + def upload_too_large(_error): + flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error") + return redirect(request.referrer or url_for("main.dashboard")) return app diff --git a/nouri/backup.py b/nouri/backup.py new file mode 100644 index 0000000..a1a8bc0 --- /dev/null +++ b/nouri/backup.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import io +import json +import shutil +import sqlite3 +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path + + +BACKUP_FILENAME_PREFIX = "nouri-backup" +RESTORE_CONFIRMATION_TEXT = "WIEDERHERSTELLEN" + + +def list_backup_tables(database: sqlite3.Connection) -> list[str]: + rows = database.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + """ + ).fetchall() + return [row["name"] for row in rows] + + +def export_backup_archive( + database: sqlite3.Connection, + upload_folder: str | Path, + app_version: str, +) -> tuple[str, str]: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + backup_name = f"{BACKUP_FILENAME_PREFIX}-{timestamp}.zip" + temp_handle = tempfile.NamedTemporaryFile(prefix="nouri-backup-", suffix=".zip", delete=False) + temp_handle.close() + archive_path = temp_handle.name + + tables = list_backup_tables(database) + payload = { + "meta": { + "created_at": datetime.now(timezone.utc).isoformat(), + "app_version": app_version, + "format_version": 1, + }, + "tables": {}, + } + + for table_name in tables: + rows = database.execute(f"SELECT * FROM {table_name}").fetchall() + payload["tables"][table_name] = [dict(row) for row in rows] + + uploads_root = Path(upload_folder) + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2)) + if uploads_root.exists(): + for file_path in uploads_root.rglob("*"): + if file_path.is_file(): + relative_path = file_path.relative_to(uploads_root) + archive.write(file_path, f"uploads/{relative_path.as_posix()}") + + return archive_path, backup_name + + +def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path: + temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-")) + for member in archive.infolist(): + if not member.filename.startswith("uploads/") or member.is_dir(): + continue + relative_target = member.filename.removeprefix("uploads/").lstrip("/") + if not relative_target: + continue + target_path = temp_dir / relative_target + target_path.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member, "r") as source, target_path.open("wb") as destination: + shutil.copyfileobj(source, destination) + return temp_dir + + +def _replace_uploads(temp_dir: Path, upload_folder: str | Path) -> None: + upload_root = Path(upload_folder) + previous_root = upload_root.with_name(f"{upload_root.name}-previous") + if previous_root.exists(): + shutil.rmtree(previous_root) + if upload_root.exists(): + upload_root.rename(previous_root) + upload_root.mkdir(parents=True, exist_ok=True) + for file_path in temp_dir.rglob("*"): + if not file_path.is_file(): + continue + relative_path = file_path.relative_to(temp_dir) + target_path = upload_root / relative_path + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(file_path, target_path) + shutil.rmtree(temp_dir, ignore_errors=True) + shutil.rmtree(previous_root, ignore_errors=True) + + +def restore_backup_archive( + database: sqlite3.Connection, + upload_folder: str | Path, + backup_file, +) -> dict: + backup_bytes = backup_file.read() + if not backup_bytes: + raise ValueError("Bitte ein gültiges Backup auswählen.") + + with zipfile.ZipFile(io.BytesIO(backup_bytes)) as archive: + try: + backup_payload = json.loads(archive.read("backup.json").decode("utf-8")) + except KeyError as exc: + raise ValueError("Im Backup fehlt die Datei backup.json.") from exc + except json.JSONDecodeError as exc: + raise ValueError("Das Backup konnte nicht gelesen werden.") from exc + + tables = backup_payload.get("tables") + if not isinstance(tables, dict): + raise ValueError("Das Backup enthält keine gültigen Tabellen-Daten.") + + current_tables = list_backup_tables(database) + restore_tables = [table for table in current_tables if table in tables] + + upload_temp_dir = _extract_uploads_to_temp(archive) + + try: + database.execute("PRAGMA foreign_keys = OFF") + try: + for table_name in reversed(restore_tables): + database.execute(f"DELETE FROM {table_name}") + database.execute("DELETE FROM sqlite_sequence") + + for table_name in restore_tables: + rows = tables.get(table_name, []) + if not rows: + continue + columns = list(rows[0].keys()) + placeholders = ", ".join(["?"] * len(columns)) + column_list = ", ".join(columns) + for row in rows: + values = [row.get(column) for column in columns] + database.execute( + f"INSERT INTO {table_name} ({column_list}) VALUES ({placeholders})", + values, + ) + finally: + database.execute("PRAGMA foreign_keys = ON") + + _replace_uploads(upload_temp_dir, upload_folder) + except Exception: + shutil.rmtree(upload_temp_dir, ignore_errors=True) + raise + + return backup_payload.get("meta", {}) diff --git a/nouri/constants.py b/nouri/constants.py index 6779f28..039f349 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -8,7 +8,7 @@ DAYPARTS = [ ] DEFAULT_CATEGORIES = [ - "Brot & Getreide", + "Kohlenhydrate", "Milchprodukt", "Obst", "Gemüse", @@ -21,6 +21,7 @@ DEFAULT_CATEGORIES = [ ] DEFAULT_CATEGORY_BUILDERS = { + "Kohlenhydrate": "carb", "Brot & Getreide": "carb", "Milchprodukt": "dairy", "Obst": "fruit", diff --git a/nouri/db.py b/nouri/db.py index e7602e7..001055b 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -10,6 +10,8 @@ from werkzeug.security import generate_password_hash from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS +CURRENT_SCHEMA_VERSION = "0.6.0" + def get_db() -> sqlite3.Connection: if "db" not in g: @@ -47,7 +49,36 @@ def add_column_if_missing(database: sqlite3.Connection, table_name: str, definit database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}") +def ensure_meta_table(database: sqlite3.Connection) -> None: + database.execute( + """ + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def get_meta(database: sqlite3.Connection, key: str) -> str | None: + row = database.execute("SELECT value FROM app_meta WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + + +def set_meta(database: sqlite3.Connection, key: str, value: str) -> None: + database.execute( + """ + INSERT INTO app_meta (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + """, + (key, value), + ) + + def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: + ensure_meta_table(database) database.execute( """ CREATE TABLE IF NOT EXISTS households ( @@ -211,6 +242,41 @@ def first_user_id(database: sqlite3.Connection) -> int | None: def sync_default_categories(database: sqlite3.Connection) -> None: for household_id in household_ids(database): + legacy = database.execute( + """ + SELECT id + FROM household_categories + WHERE household_id = ? AND name = 'Brot & Getreide' + LIMIT 1 + """, + (household_id,), + ).fetchone() + updated = database.execute( + """ + SELECT id + FROM household_categories + WHERE household_id = ? AND name = 'Kohlenhydrate' + LIMIT 1 + """, + (household_id,), + ).fetchone() + if legacy and not updated: + database.execute( + """ + UPDATE household_categories + SET name = 'Kohlenhydrate', builder_key = 'carb' + WHERE id = ? + """, + (legacy["id"],), + ) + database.execute( + """ + UPDATE items + SET category = 'Kohlenhydrate' + WHERE household_id = ? AND category = 'Brot & Getreide' + """, + (household_id,), + ) for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10): database.execute( """ @@ -230,6 +296,7 @@ def sync_default_categories(database: sqlite3.Connection) -> None: def ensure_schema_upgrades(database: sqlite3.Connection) -> None: + ensure_meta_table(database) add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") @@ -237,10 +304,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "users", "updated_at TEXT") default_household_id = ensure_default_household(database) - database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)") - database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)") + database.execute("UPDATE households SET shopping_weekday = 5 WHERE shopping_weekday IS NULL") + database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL") database.execute( - "UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')" + "UPDATE households SET shopping_reminder_time = '18:00' WHERE shopping_reminder_time IS NULL OR shopping_reminder_time = ''" ) database.execute( "UPDATE users SET household_id = ? WHERE household_id IS NULL", @@ -349,6 +416,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_needs (household_id, activation_date, is_activated) """ ) + set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION) def apply_schema(database: sqlite3.Connection) -> None: diff --git a/nouri/images.py b/nouri/images.py new file mode 100644 index 0000000..47dcb4f --- /dev/null +++ b/nouri/images.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +import uuid +from pathlib import Path + +from PIL import Image, ImageOps, UnidentifiedImageError +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + + +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +IMAGE_VARIANTS = { + "sm": {"width": 320, "quality": 76}, + "md": {"width": 720, "quality": 82}, + "lg": {"width": 1280, "quality": 86}, +} +DEFAULT_RENDERED_FORMAT = "webp" +ORIGINAL_MAX_WIDTH = 1600 + + +def allowed_image_file(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + + +def ensure_upload_structure(upload_folder: str | Path) -> None: + upload_root = Path(upload_folder) + upload_root.mkdir(parents=True, exist_ok=True) + (upload_root / "variants").mkdir(parents=True, exist_ok=True) + for variant_name in IMAGE_VARIANTS: + (upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True) + + +def build_variant_filename(filename: str, variant_name: str) -> str: + source = Path(filename) + return f"{source.stem}__{variant_name}.webp" + + +def build_variant_relative_path(filename: str, variant_name: str) -> str: + return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}" + + +def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None: + if not filename: + return + upload_root = Path(upload_folder) + original_path = upload_root / filename + if original_path.exists(): + original_path.unlink() + for variant_name in IMAGE_VARIANTS: + variant_path = upload_root / build_variant_relative_path(filename, variant_name) + if variant_path.exists(): + variant_path.unlink() + + +def _open_image(upload: FileStorage) -> Image.Image: + upload.stream.seek(0) + image = Image.open(upload.stream) + image.load() + return ImageOps.exif_transpose(image) + + +def _prepare_image(image: Image.Image) -> Image.Image: + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA" if "A" in image.getbands() else "RGB") + return image + + +def _resize_copy(image: Image.Image, width: int) -> Image.Image: + resized = image.copy() + resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS) + return resized + + +def _save_image(image: Image.Image, destination: Path, quality: int) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + image.save( + destination, + format=DEFAULT_RENDERED_FORMAT.upper(), + quality=quality, + method=6, + ) + + +def save_photo_with_variants( + upload: FileStorage | None, + upload_folder: str | Path, + current_filename: str | None = None, +) -> str | None: + if not upload or not upload.filename: + return current_filename + + if not allowed_image_file(upload.filename): + raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") + + ensure_upload_structure(upload_folder) + + original_name = secure_filename(upload.filename) + extension = original_name.rsplit(".", 1)[1].lower() + + try: + image = _prepare_image(_open_image(upload)) + filename = f"{uuid.uuid4().hex}.webp" + original_path = Path(upload_folder) / filename + optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH) + _save_image(optimized, original_path, quality=88) + for variant_name, config in IMAGE_VARIANTS.items(): + variant_image = _resize_copy(image, int(config["width"])) + variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name) + _save_image(variant_image, variant_path, quality=int(config["quality"])) + except (UnidentifiedImageError, OSError, ValueError): + filename = f"{uuid.uuid4().hex}.{extension}" + original_path = Path(upload_folder) / filename + upload.stream.seek(0) + upload.save(original_path) + + if current_filename and current_filename != filename: + remove_photo_assets(upload_folder, current_filename) + + return filename + + +def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None: + if not filename: + return None + if variant in IMAGE_VARIANTS: + if upload_folder is not None: + variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant) + if variant_path.exists(): + return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) + else: + return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) + return url_builder("uploaded_file", filename=filename) + + +def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str: + if not filename: + return "" + parts = [] + for variant_name, config in IMAGE_VARIANTS.items(): + variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder) + if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)): + parts.append(f"{variant_url} {config['width']}w") + original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320 + parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w") + return ", ".join(parts) + + +def image_sizes(card: str = "grid") -> str: + if card == "detail": + return "(max-width: 720px) 100vw, 720px" + return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px" + + +def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool: + if not upload or not upload.filename: + return True + stream = upload.stream + current_position = stream.tell() + stream.seek(0, os.SEEK_END) + size = stream.tell() + stream.seek(current_position) + return size <= max_bytes diff --git a/nouri/main.py b/nouri/main.py index c27fd45..88a3dd7 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -1,12 +1,13 @@ from __future__ import annotations -import uuid from collections import defaultdict from datetime import date, datetime, timedelta +from itertools import product from pathlib import Path from flask import ( Blueprint, + after_this_request, current_app, flash, g, @@ -14,11 +15,12 @@ from flask import ( redirect, render_template, request, + send_file, url_for, ) -from werkzeug.utils import secure_filename -from .auth import login_required +from .auth import admin_required, login_required +from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive from .constants import ( AVAILABILITY_LABELS, BUILDER_LABELS, @@ -35,12 +37,15 @@ from .constants import ( WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db +from .images import ( + allowed_image_file, + save_photo_with_variants, + upload_file_size_ok, +) from .push import push_is_configured, push_public_key, send_push_message main_bp = Blueprint("main", __name__) - -ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} ACTIVE_STATE_OPTIONS = [ ("", "Alle aktiven"), ("home", "Zuhause"), @@ -260,29 +265,14 @@ def normalize_target_user_id(raw: str | None) -> int | None: return target_id if target_id in allowed else None -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): + if not allowed_image_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 + if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]): + raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.") + return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename) def user_display_name(display_name: str | None, username: str | None) -> str: @@ -1156,18 +1146,98 @@ def format_item_names(items: list[dict], limit: int = 3) -> str: return ", ".join(item["name"] for item in items[:limit]) -def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: - home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id) - home_food_ids = {item["id"] for item in home_foods} +def item_matches_daypart(item: dict, daypart_id: int | None) -> bool: + if daypart_id is None: + return True + dayparts_meta = item.get("dayparts_meta") or [] + if not dayparts_meta: + return True + return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta) + + +def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]: + return tuple(sorted({int(component_id) for component_id in component_ids})) + + +def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: + names = [item["name"] for item in combo] + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: + return f"{names[0]} mit {', '.join(names[1:])}" + if len(names) >= 2: + return f"{names[0]} mit {', '.join(names[1:])}" + return names[0] + + +def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: builder_groups: dict[str, list[dict]] = defaultdict(list) for food in home_foods: for builder_key in food.get("builder_keys", ["neutral"]): builder_groups[builder_key].append(food) + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: + target_patterns = [ + ("carb", "dairy", "fruit"), + ("carb", "dairy", "nuts"), + ("carb", "fruit", "dairy"), + ] + reasons = { + ("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack", + ("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken", + ("carb", "fruit", "dairy"): "Zuhause gut kombinierbar", + } + else: + target_patterns = [ + ("protein", "carb", "veg"), + ("protein", "carb"), + ] + reasons = { + ("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich", + ("protein", "carb"): "Lässt sich leicht ergänzen", + } + suggestions: list[dict] = [] - meals = fetch_items(kind="meal", daypart_id=daypart_id) + seen_signatures: set[tuple[int, ...]] = set() + + for pattern in target_patterns: + groups = [builder_groups.get(builder_key, []) for builder_key in pattern] + if any(not group for group in groups): + continue + for combo in product(*groups): + signature = normalized_component_signature([item["id"] for item in combo]) + if len(signature) != len(pattern) or signature in seen_signatures: + continue + seen_signatures.add(signature) + combo_items = list(combo) + suggestions.append( + { + "title": build_generated_meal_name(combo_items, daypart_slug), + "reason": reasons.get(pattern, "Zuhause gut kombinierbar"), + "component_ids": [item["id"] for item in combo_items], + "existing_item_id": None, + } + ) + if len(suggestions) >= limit: + return suggestions + return suggestions + + +def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: + home_foods = [ + item + for item in fetch_items(kind="food", availability="home") + if item_matches_daypart(item, daypart_id) + ] + home_food_ids = {item["id"] for item in home_foods} + + suggestions: list[dict] = [] + seen_signatures: set[tuple[int, ...]] = set() + meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] for meal in meals: if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): + signature = normalized_component_signature(meal["component_ids"]) + if signature in seen_signatures: + continue + seen_signatures.add(signature) suggestions.append( { "title": meal["name"], @@ -1178,34 +1248,12 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) ) daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") - if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: - if builder_groups["carb"] and builder_groups["dairy"]: - combo = [builder_groups["carb"][0], builder_groups["dairy"][0]] - if builder_groups["fruit"]: - combo.append(builder_groups["fruit"][0]) - if builder_groups["nuts"]: - combo.append(builder_groups["nuts"][0]) - suggestions.append( - { - "title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", - "reason": "Lässt sich gut ergänzen", - "component_ids": [item["id"] for item in combo], - "existing_item_id": None, - } - ) - else: - if builder_groups["protein"] and builder_groups["carb"]: - combo = [builder_groups["protein"][0], builder_groups["carb"][0]] - if builder_groups["veg"]: - combo.append(builder_groups["veg"][0]) - suggestions.append( - { - "title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", - "reason": "Aus Zuhause zusammengesetzt", - "component_ids": [item["id"] for item in combo], - "existing_item_id": None, - } - ) + for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): + signature = normalized_component_signature(suggestion["component_ids"]) + if signature in seen_signatures: + continue + seen_signatures.add(signature) + suggestions.append(suggestion) deduped: list[dict] = [] seen = set() @@ -1336,6 +1384,77 @@ def build_dashboard_hints(today: date) -> list[str]: return hints[:4] +def build_setup_checklist(today: date) -> list[dict]: + total_items = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}", + visible_params(), + ).fetchone()["count"] + ) + meal_count = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}", + visible_params(), + ).fetchone()["count"] + ) + week_end = today + timedelta(days=6) + plan_count = int( + get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')} + """, + [today.isoformat(), week_end.isoformat(), *visible_params()], + ).fetchone()["count"] + ) + template_count = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM day_templates WHERE {visible_clause('day_templates')}", + visible_params(), + ).fetchone()["count"] + ) + + checklist = [] + if total_items == 0: + checklist.append( + { + "title": "Fang mit einem ersten Lebensmittel an", + "text": "Ein kleines Frühstück, ein Snack oder etwas für zuhause reicht völlig für den Start.", + "url": url_for("main.item_create", kind="food"), + "label": "Lebensmittel anlegen", + } + ) + if total_items > 0 and meal_count == 0: + checklist.append( + { + "title": "Lege eine erste Mahlzeitenidee an", + "text": "Einfach zwei oder drei vertraute Dinge zusammenklicken und für später merken.", + "url": url_for("main.item_create", kind="meal"), + "label": "Mahlzeit anlegen", + } + ) + if plan_count == 0: + checklist.append( + { + "title": "Plane einen ruhigen ersten Tag", + "text": "Mit einem kleinen Eintrag für Frühstück oder Abendessen fühlt sich die Woche sofort greifbarer an.", + "url": url_for("main.planner_day", date=today.isoformat()), + "label": "Tag öffnen", + } + ) + if total_items > 0 and template_count == 0: + checklist.append( + { + "title": "Merke dir einen gelungenen Tag als Vorlage", + "text": "So wird Wiederverwendung später noch leichter.", + "url": url_for("main.template_library"), + "label": "Vorlagen ansehen", + } + ) + return checklist[:3] + + def build_day_hints(selected_date: date) -> list[str]: settings = get_user_settings() if not settings.get("reminders_enabled"): @@ -1975,6 +2094,28 @@ def create_or_get_generated_meal( daypart_id: int, visibility: str, ) -> int: + normalized_ids = normalized_component_signature(component_ids) + existing_meals = [ + item + for item in fetch_items(kind="meal") + if normalized_component_signature(item.get("component_ids", [])) == normalized_ids + ] + if existing_meals: + meal_id = int(existing_meals[0]["id"]) + current_dayparts = get_item_daypart_ids(meal_id) + if daypart_id not in current_dayparts: + sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) + get_db().execute( + """ + UPDATE items + SET updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], meal_id), + ) + get_db().commit() + return meal_id + existing = get_db().execute( f""" SELECT items.id @@ -1986,7 +2127,21 @@ def create_or_get_generated_meal( [name, *visible_params()], ).fetchone() if existing: - return int(existing["id"]) + meal_id = int(existing["id"]) + sync_meal_components(meal_id, list(normalized_ids)) + current_dayparts = get_item_daypart_ids(meal_id) + if daypart_id not in current_dayparts: + sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) + get_db().execute( + """ + UPDATE items + SET updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], meal_id), + ) + get_db().commit() + return meal_id cursor = get_db().execute( """ @@ -2000,14 +2155,14 @@ def create_or_get_generated_meal( g.user["id"], visibility, name, - "Kleines Essen", + "Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes", g.user["id"], g.user["id"], ), ) meal_id = int(cursor.lastrowid) sync_item_dayparts(meal_id, [daypart_id]) - sync_meal_components(meal_id, component_ids) + sync_meal_components(meal_id, list(normalized_ids)) get_db().commit() return meal_id @@ -2074,6 +2229,7 @@ def dashboard(): upcoming_entries=fetch_upcoming_shopping_needs(limit=4), day_templates=fetch_day_templates()[:3], week_templates=fetch_week_templates()[:3], + setup_checklist=build_setup_checklist(today), ) @@ -2525,9 +2681,60 @@ def settings_view(): push_subscription_count=int(push_subscription["count"]), push_ready=push_is_configured(), push_public_key_value=push_public_key(), + restore_confirmation_text=RESTORE_CONFIRMATION_TEXT, ) +@main_bp.get("/settings/backup/export") +@login_required +@admin_required +def backup_export(): + archive_path, download_name = export_backup_archive( + get_db(), + current_app.config["UPLOAD_FOLDER"], + current_app.config["APP_VERSION"], + ) + + @after_this_request + def cleanup_backup(response): + Path(archive_path).unlink(missing_ok=True) + return response + + return send_file( + archive_path, + as_attachment=True, + download_name=download_name, + mimetype="application/zip", + max_age=0, + ) + + +@main_bp.post("/settings/backup/restore") +@login_required +@admin_required +def backup_restore(): + confirmation = request.form.get("restore_confirmation", "").strip().upper() + backup_file = request.files.get("backup_file") + if confirmation != RESTORE_CONFIRMATION_TEXT: + flash("Bitte die Bestätigung genau eintragen, bevor das Backup wiederhergestellt wird.", "error") + return redirect(url_for("main.settings_view")) + if not backup_file or not backup_file.filename: + flash("Bitte zuerst eine Backup-Datei auswählen.", "error") + return redirect(url_for("main.settings_view")) + + try: + metadata = restore_backup_archive(get_db(), current_app.config["UPLOAD_FOLDER"], backup_file) + get_db().commit() + except Exception as exc: + get_db().rollback() + flash(str(exc) or "Das Backup konnte gerade nicht wiederhergestellt werden.", "error") + return redirect(url_for("main.settings_view")) + + version_label = metadata.get("app_version") or "einer älteren Version" + flash(f"Das Backup aus {version_label} wurde wiederhergestellt.", "success") + return redirect(url_for("main.settings_view")) + + @main_bp.post("/push/subscribe") @login_required def push_subscribe(): diff --git a/nouri/schema.sql b/nouri/schema.sql index c712481..d539257 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -1,5 +1,11 @@ PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, diff --git a/nouri/static/brand/favicon.svg b/nouri/static/brand/favicon.svg index fe1b8a0..15e14bc 100644 --- a/nouri/static/brand/favicon.svg +++ b/nouri/static/brand/favicon.svg @@ -1,16 +1,6 @@ - - - - - - - - - - - - - - - + + + + + diff --git a/nouri/static/brand/nouri-icon.svg b/nouri/static/brand/nouri-icon.svg index 662d605..8b64f2f 100644 --- a/nouri/static/brand/nouri-icon.svg +++ b/nouri/static/brand/nouri-icon.svg @@ -1,21 +1,20 @@ - - Nouri + - - - - + + + - - - + + + - - - - - - - + + + + + + + + diff --git a/nouri/static/brand/pwa-180.png b/nouri/static/brand/pwa-180.png new file mode 100644 index 0000000..1d80a42 Binary files /dev/null and b/nouri/static/brand/pwa-180.png differ diff --git a/nouri/static/brand/pwa-192.png b/nouri/static/brand/pwa-192.png index 24f0a95..5b8cca9 100644 Binary files a/nouri/static/brand/pwa-192.png and b/nouri/static/brand/pwa-192.png differ diff --git a/nouri/static/brand/pwa-512.png b/nouri/static/brand/pwa-512.png index 145a5cf..c6ef3b3 100644 Binary files a/nouri/static/brand/pwa-512.png and b/nouri/static/brand/pwa-512.png differ diff --git a/nouri/static/brand/pwa-badge.png b/nouri/static/brand/pwa-badge.png index 7f962fb..0a42052 100644 Binary files a/nouri/static/brand/pwa-badge.png and b/nouri/static/brand/pwa-badge.png differ diff --git a/nouri/static/brand/pwa-maskable-512.png b/nouri/static/brand/pwa-maskable-512.png new file mode 100644 index 0000000..83ece23 Binary files /dev/null and b/nouri/static/brand/pwa-maskable-512.png differ diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index cb658a2..5e232eb 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -103,6 +103,17 @@ button, transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; } +button:focus-visible, +.button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-strong) 78%, white 22%); + outline-offset: 3px; +} + button:hover, .button:hover { transform: translateY(-1px); @@ -327,6 +338,12 @@ h3, linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%)); } +.hero h1, +.page-intro h1, +.panel h2 { + text-wrap: balance; +} + .eyebrow { margin: 0 0 0.45rem; text-transform: uppercase; @@ -369,6 +386,10 @@ h3 { line-height: 1.6; } +.empty-panel { + text-align: left; +} + .intro-pills, .chip-row { display: flex; @@ -598,6 +619,21 @@ h3 { width: min(560px, 100%); } +.setup-intro-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + margin: 1.1rem 0 1.25rem; +} + +.setup-tip, +.restore-warning { + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); +} + .stack-form, .stack-sections, .planner-day-stack, @@ -714,6 +750,7 @@ legend { width: min(220px, 100%); border-radius: 18px; border: 1px solid var(--line); + box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12); } .compact-form-panel { @@ -947,6 +984,12 @@ legend { background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); } +.restore-warning strong, +.setup-tip strong { + display: block; + margin-bottom: 0.3rem; +} + .card-link-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; @@ -1259,18 +1302,14 @@ legend { } body.has-mobile-nav { - padding-top: 5.3rem; + padding-top: 0; } .site-header { - position: fixed; - top: calc(env(safe-area-inset-top, 0px) + 0.5rem); - left: 0.5rem; - right: 0.5rem; + position: static; grid-template-columns: 1fr auto; - z-index: 30; - width: auto; - margin: 0; + width: 100%; + margin: 0 0 1.15rem; } .stats-grid, @@ -1303,9 +1342,8 @@ legend { grid-template-columns: 1fr auto; gap: 0.6rem; padding: 0.75rem 0.9rem; - margin-bottom: 0; + margin-bottom: 1rem; border-radius: 22px; - z-index: 30; } .desktop-nav, @@ -1369,6 +1407,10 @@ legend { grid-template-columns: 1fr; } + .setup-intro-grid { + grid-template-columns: 1fr; + } + .item-card { grid-template-columns: 1fr; } diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index b9dc948..ff89a60 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -51,12 +51,57 @@ if (!container) return; const items = Array.from(container.querySelectorAll("[data-filter-label]")); + const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]")); + const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10); + const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0; + + const scoreItem = (label, term) => { + if (label === term) return 0; + if (label.startsWith(term)) return 1; + if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2; + if (label.includes(term)) return 3; + return 99; + }; + + const syncGroups = () => { + filterGroups.forEach((group) => { + const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden); + const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection"); + if (card) { + card.hidden = !visibleChildren; + } else { + group.hidden = !visibleChildren; + } + }); + }; + const applyFilter = () => { const term = input.value.trim().toLowerCase(); + if (!term) { + items.forEach((item) => { + item.hidden = false; + }); + syncGroups(); + return; + } + + const rankedMatches = items + .map((item, index) => { + const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); + const score = scoreItem(haystack, term); + return { item, index, score, matches: score < 99 }; + }) + .filter((entry) => entry.matches) + .sort((left, right) => left.score - right.score || left.index - right.index); + + const allowedItems = new Set( + (hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item) + ); + items.forEach((item) => { - const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); - item.hidden = Boolean(term) && !haystack.includes(term); + item.hidden = !allowedItems.has(item); }); + syncGroups(); }; input.addEventListener("input", applyFilter); diff --git a/nouri/static/pwa/app.webmanifest b/nouri/static/pwa/app.webmanifest index 4a89c98..3e4d677 100644 --- a/nouri/static/pwa/app.webmanifest +++ b/nouri/static/pwa/app.webmanifest @@ -6,9 +6,16 @@ "start_url": "/", "scope": "/", "display": "standalone", + "display_override": ["standalone", "minimal-ui"], "background_color": "#fff6ef", - "theme_color": "#efab72", + "theme_color": "#de9862", + "categories": ["food", "lifestyle", "productivity"], "icons": [ + { + "src": "/static/brand/pwa-180.png", + "sizes": "180x180", + "type": "image/png" + }, { "src": "/static/brand/pwa-192.png", "sizes": "192x192", @@ -18,6 +25,12 @@ "src": "/static/brand/pwa-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/static/brand/pwa-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/nouri/static/pwa/offline.html b/nouri/static/pwa/offline.html new file mode 100644 index 0000000..d3a599a --- /dev/null +++ b/nouri/static/pwa/offline.html @@ -0,0 +1,79 @@ + + + + + + Nouri offline + + + +
+

Nouri ist gerade kurz offline

+

Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.

+

Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.

+ Erneut versuchen +
+ + diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js index 599460a..3912614 100644 --- a/nouri/static/pwa/service-worker.js +++ b/nouri/static/pwa/service-worker.js @@ -1,15 +1,32 @@ -const CACHE_NAME = "nouri-v0-5-1-1"; +const CACHE_NAME = "nouri-v0-6-0"; +const OFFLINE_URL = "/static/pwa/offline.html"; const STATIC_ASSETS = [ "/static/css/styles.css", "/static/js/theme.js", "/static/js/ui.js", "/static/js/planner.js", "/static/js/pwa.js", + "/static/brand/pwa-180.png", "/static/brand/pwa-192.png", "/static/brand/pwa-512.png", + "/static/brand/pwa-maskable-512.png", + "/static/brand/pwa-badge.png", "/static/brand/favicon.svg", + "/app.webmanifest", + OFFLINE_URL, ]; +const cacheFirst = async (request) => { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response && response.ok && response.type === "basic") { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; +}; + self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting()) @@ -28,40 +45,32 @@ self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; const requestUrl = new URL(event.request.url); - const isStaticAsset = - requestUrl.origin === self.location.origin && - ( - requestUrl.pathname.startsWith("/static/") - || requestUrl.pathname === "/app.webmanifest" - || requestUrl.pathname === "/service-worker.js" - ); + const isSameOrigin = requestUrl.origin === self.location.origin; + const isStaticAsset = isSameOrigin && ( + requestUrl.pathname.startsWith("/static/") + || requestUrl.pathname === "/app.webmanifest" + || requestUrl.pathname === "/service-worker.js" + ); + const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/"); if (event.request.mode === "navigate") { event.respondWith( - fetch(event.request).catch(() => caches.match(event.request)) + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + return response; + }) + .catch(async () => { + return (await caches.match(event.request)) || caches.match(OFFLINE_URL); + }) ); return; } - if (!isStaticAsset) { - return; + if (isStaticAsset || isUpload) { + event.respondWith(cacheFirst(event.request)); } - - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) { - return cached; - } - return fetch(event.request).then((response) => { - if (!response || response.status !== 200 || response.type !== "basic") { - return response; - } - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - return response; - }); - }) - ); }); self.addEventListener("push", (event) => { diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index 8f9ec94..1217820 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -44,7 +44,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/auth/setup.html b/nouri/templates/auth/setup.html index 641b30c..607fd76 100644 --- a/nouri/templates/auth/setup.html +++ b/nouri/templates/auth/setup.html @@ -5,7 +5,22 @@

Erster Start

Den ersten Haushalt-Zugang anlegen

-

Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.

+

Danach könnt ihr Nouri gemeinsam nutzen. Der erste Einstieg bleibt bewusst klein: Zugang anlegen, Einkaufstag festlegen, erste Lebensmittel sammeln und bei Bedarf später eine Tagesvorlage merken.

+ +
+
+ 1. Ruhig starten +

Ein erster Haushalt und ein Admin-Zugang reichen für den Anfang völlig.

+
+
+ 2. Alltag festhalten +

Später könnt ihr Lebensmittel, Mahlzeitenideen und Planungen gemeinsam nutzen.

+
+
+ 3. Als App nutzen +

Auf dem iPhone lässt sich Nouri später über Safari zum Home-Bildschirm hinzufügen.

+
+
{{ csrf_input() }} diff --git a/nouri/templates/base.html b/nouri/templates/base.html index 7cb78f6..c6475e3 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -4,27 +4,29 @@ {% block title %}Nouri{% endblock %} - + + + - - + + - - - - - + + + + + +{% if setup_checklist %} +
+
+

Gut anfangen

+
+
+
+{% endif %} +
Zuhause diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index 5a5bdf6..742a083 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -72,7 +72,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index ce0445b..edc8570 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -67,7 +67,12 @@ {% if item and item.photo_filename %}
- {{ item.name }} + {{ item.name }}
{% endif %} @@ -97,10 +102,12 @@ placeholder="z. B. Reis, Banane, Joghurt" data-filter-input data-filter-target="#meal-components-list" + data-filter-limit="3" >
+

Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.

{% if food_groups %}
{% for group in food_groups %} diff --git a/nouri/templates/items/list.html b/nouri/templates/items/list.html index 57c3d63..bb3fc2f 100644 --- a/nouri/templates/items/list.html +++ b/nouri/templates/items/list.html @@ -54,7 +54,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/settings.html b/nouri/templates/settings.html index 9d57c9c..06a40df 100644 --- a/nouri/templates/settings.html +++ b/nouri/templates/settings.html @@ -4,8 +4,8 @@

Optionen

-

Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen

-

Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.

+

Ruhige Einstellungen für Alltag, Sicherung und iPhone-Nutzung

+

Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und wie Nouri sich auf dem Home-Bildschirm oder beim Backup verhalten soll.

@@ -33,23 +33,29 @@ Erinnerung ungefähr um - +
+ +
-

Home-Bildschirm & Push

+

Für den Homescreen

- Als Web-App nutzen -

Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.

+ Auf dem iPhone installieren +

Öffne Nouri in Safari, tippe auf Teilen und dann auf Zum Home-Bildschirm. Danach startet Nouri deutlich app-näher und ruhiger.

+
+
+ Offline etwas stabiler +

Die wichtigsten Oberflächen und Brand-Dateien bleiben lokal greifbar. Wenn das Netz kurz weg ist, wirkt die App dadurch stabiler und klarer.

Push-Mitteilungen {% if push_ready %} -

Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.

+

Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben und mit einer Test-Mitteilung prüfen.

@@ -61,7 +67,10 @@ {{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %} {% else %} -

Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.

+

Push wird sichtbar, sobald VAPID-Schlüssel für diese App gesetzt sind.

+ {% if push_public_key_value %} + Öffentlicher Schlüssel erkannt, privater Schlüssel fehlt noch. + {% endif %} {% endif %}
@@ -122,4 +131,45 @@
+ +{% if is_admin() %} +
+
+
+

Backup exportieren

+
+
+
+ Komplettes App-Backup +

Das ZIP enthält Nutzer, Einstellungen, Lebensmittel, Mahlzeiten, Vorlagen, Planungen, Einkaufsdaten und hochgeladene Bilder.

+ Backup herunterladen +
+
+
+ +
+
+

Backup wiederherstellen

+
+
+ {{ csrf_input() }} +
+ Nur bewusst verwenden +

Die Wiederherstellung ersetzt den aktuellen Datenstand dieses Haushalts. Vorher am besten selbst noch ein frisches Backup herunterladen.

+
+ + +
+ +
+
+
+
+{% endif %} {% endblock %} diff --git a/requirements.txt b/requirements.txt index 95cfb56..db71091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==3.1.1 gunicorn==23.0.0 pywebpush==2.3.0 +Pillow==11.2.1 diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py new file mode 100644 index 0000000..2237ba4 --- /dev/null +++ b/scripts/generate_brand_assets.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + + +ROOT = Path(__file__).resolve().parents[1] +BRAND_DIR = ROOT / "nouri" / "static" / "brand" + +BG_TOP = "#F6C394" +BG_BOTTOM = "#DE9862" +BOWL = "#FFF7EF" +STROKE = "#8C533B" + + +def rounded_gradient_icon(size: int, *, maskable: bool = False) -> Image.Image: + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + radius = int(size * (0.28 if maskable else 0.24)) + + for y in range(size): + blend = y / max(size - 1, 1) + r1, g1, b1 = ImageColorTuple(BG_TOP) + r2, g2, b2 = ImageColorTuple(BG_BOTTOM) + color = ( + int(r1 + (r2 - r1) * blend), + int(g1 + (g2 - g1) * blend), + int(b1 + (b2 - b1) * blend), + 255, + ) + draw.line((0, y, size, y), fill=color) + + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) + image.putalpha(mask) + + inset = int(size * (0.11 if maskable else 0.13)) + inner = [inset, inset, size - inset, size - inset] + draw.rounded_rectangle(inner, radius=int(radius * 0.84), outline=(255, 255, 255, 54), width=max(2, size // 80)) + + bowl_top = int(size * 0.24) + bowl_left = int(size * 0.27) + bowl_right = int(size * 0.73) + bowl_bottom = int(size * 0.68) + draw.rounded_rectangle( + (bowl_left, bowl_top, bowl_right, bowl_bottom), + radius=int(size * 0.16), + fill=BOWL, + ) + draw.rounded_rectangle( + (int(size * 0.31), int(size * 0.31), int(size * 0.69), int(size * 0.60)), + radius=int(size * 0.12), + fill=(239, 195, 159, 64), + ) + + bowl_curve_top = int(size * 0.56) + draw.pieslice( + (int(size * 0.24), bowl_curve_top, int(size * 0.76), int(size * 0.84)), + start=0, + end=180, + fill=(247, 179, 125, 255), + ) + + line_width = max(4, size // 26) + steam = [ + (0.50, 0.31), + (0.56, 0.31), + (0.56, 0.40), + (0.45, 0.50), + (0.45, 0.58), + ] + draw.line([(int(size * x), int(size * y)) for x, y in steam], fill=STROKE, width=line_width, joint="curve") + draw.line( + [(int(size * 0.45), int(size * 0.58)), (int(size * 0.56), int(size * 0.58))], + fill=STROKE, + width=line_width, + ) + return image + + +def ImageColorTuple(hex_color: str) -> tuple[int, int, int]: + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[index:index + 2], 16) for index in (0, 2, 4)) + + +def badge_icon(size: int) -> Image.Image: + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=size // 3, fill=BG_BOTTOM) + draw.ellipse((size * 0.18, size * 0.18, size * 0.82, size * 0.82), fill=BOWL) + draw.rectangle((size * 0.34, size * 0.52, size * 0.66, size * 0.63), fill=STROKE) + return image + + +def save_assets() -> None: + BRAND_DIR.mkdir(parents=True, exist_ok=True) + rounded_gradient_icon(180).save(BRAND_DIR / "pwa-180.png") + rounded_gradient_icon(192).save(BRAND_DIR / "pwa-192.png") + rounded_gradient_icon(512).save(BRAND_DIR / "pwa-512.png") + rounded_gradient_icon(512, maskable=True).save(BRAND_DIR / "pwa-maskable-512.png") + badge_icon(96).save(BRAND_DIR / "pwa-badge.png") + + +if __name__ == "__main__": + save_assets()