7 Commits

37 changed files with 1625 additions and 282 deletions
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
data/ data/
instance/ instance/
.cloudron-push.env
.env.local
.env.push.local
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz", "author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning", "description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen", "tagline": "einfach essen planen",
"version": "0.5.0", "version": "0.6.0",
"upstreamVersion": "0.5.0", "upstreamVersion": "0.6.0",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+2 -1
View File
@@ -11,7 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
sqlite3 \ sqlite3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN useradd -r -m -d /home/cloudron cloudron RUN groupadd --gid 1000 cloudron \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
COPY requirements.txt /app/code/ COPY requirements.txt /app/code/
RUN pip install --no-cache-dir -r requirements.txt gunicorn RUN pip install --no-cache-dir -r requirements.txt gunicorn
+41
View File
@@ -0,0 +1,41 @@
# Push-Setup für Nouri
## 1. VAPID-Schlüssel erzeugen
```bash
. .venv/bin/activate
python scripts/generate_vapid_keys.py
```
Das Script gibt drei Zeilen aus:
- `NOURI_VAPID_PUBLIC_KEY`
- `NOURI_VAPID_PRIVATE_KEY`
- `NOURI_VAPID_SUBJECT`
## 2. In Cloudron eintragen
In der bestehenden Nouri-App unter `Settings``Environment Variables` diese drei Werte anlegen:
```text
NOURI_VAPID_PUBLIC_KEY=...
NOURI_VAPID_PRIVATE_KEY=...
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
```
Danach die App neu starten.
## 3. Auf dem iPhone aktivieren
1. `nouri.heinz.media` in Safari öffnen
2. `Teilen``Zum Home-Bildschirm`
3. die installierte Web-App öffnen
4. in Nouri `Optionen` öffnen
5. `Push erlauben` tippen
6. danach optional `Test-Mitteilung senden`
## 4. Bereits vorbereitete lokale Datei
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
+32
View File
@@ -0,0 +1,32 @@
# Nouri 0.5.1
## Highlights
- Smartphone-Navigation unten neu als echte Erweiterung umgesetzt
- obere Nouri-Leiste auf kleinen Geräten nicht mehr sticky, sondern sauber fest positioniert
- PWA-Cache für frische Layout- und Einstellungsänderungen bereinigt
- Cloudron-Version auf `0.5.1` angehoben
## Neu in 0.5.1
### Mobile Navigation
- `Mehr` ist auf Smartphones kein schwebendes Overlay mehr.
- Die zusätzlichen Punkte klappen jetzt direkt aus der unteren Navigation heraus auf.
- Die Zusatzpunkte nutzen dieselbe kompakte Größe wie die unteren Menüpunkte.
- Der untere Navigationsbereich wird dabei nicht weichgezeichnet.
### Mobile Header
- Die obere Nouri-Leiste scrollt auf kleinen Geräten nicht mehr mit dem Inhalt.
- Die bisherige `sticky`-Logik für den Header wurde entfernt, damit es keine widersprüchlichen Zustände mehr gibt.
### PWA
- Der Service Worker verwendet einen aktualisierten Cache-Namen.
- Navigationsseiten werden frischer geladen, damit Änderungen an Einstellungen und Layout nicht an altem Cache hängen bleiben.
## Cloudron
- `CloudronManifest.json` wurde auf `0.5.1` angehoben.
- Damit lässt sich das Update sauber als neue App-Version ausrollen.
+57
View File
@@ -0,0 +1,57 @@
# 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
- Desktop-Navigation wieder klarer als durchlaufende Leiste direkt neben dem Logo
### 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
- Startet lokal jetzt auch ohne Pillow-Build auf Python 3.14, statt schon beim Import zu scheitern
### 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 für Bildverarbeitung ergänzt und auf Python 3.14 lokal als optionale Abhängigkeit abgefedert
## 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.
+55 -6
View File
@@ -5,7 +5,7 @@ import secrets
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path 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 . import db
from .admin import admin_bp from .admin import admin_bp
@@ -24,6 +24,7 @@ from .constants import (
VISIBILITY_LABELS, VISIBILITY_LABELS,
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
) )
from .images import ensure_upload_structure, image_sizes, image_srcset, image_url
from .main import main_bp from .main import main_bp
@@ -56,7 +57,7 @@ def create_app() -> Flask:
db_path = data_dir / "nouri.sqlite3" db_path = data_dir / "nouri.sqlite3"
data_dir.mkdir(parents=True, exist_ok=True) 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 = Flask(__name__, instance_relative_config=False)
app.config.update( app.config.update(
@@ -68,7 +69,7 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30), PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
APP_VERSION="0.5.0", APP_VERSION="0.6.0",
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
@@ -83,6 +84,11 @@ def create_app() -> Flask:
@app.context_processor @app.context_processor
def inject_globals() -> dict[str, object]: 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 { return {
"item_kind_labels": ITEM_KIND_LABELS, "item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_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_name": lambda value: WEEKDAY_NAMES[value.weekday()],
"weekday_short_name": lambda value: WEEKDAY_SHORT_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", "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/<path:filename>") @app.get("/uploads/<path:filename>")
def uploaded_file(filename: str): 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") @app.get("/app.webmanifest")
def 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") @app.get("/service-worker.js")
def service_worker(): 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 return app
+154
View File
@@ -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", {})
+2 -1
View File
@@ -8,7 +8,7 @@ DAYPARTS = [
] ]
DEFAULT_CATEGORIES = [ DEFAULT_CATEGORIES = [
"Brot & Getreide", "Kohlenhydrate",
"Milchprodukt", "Milchprodukt",
"Obst", "Obst",
"Gemüse", "Gemüse",
@@ -21,6 +21,7 @@ DEFAULT_CATEGORIES = [
] ]
DEFAULT_CATEGORY_BUILDERS = { DEFAULT_CATEGORY_BUILDERS = {
"Kohlenhydrate": "carb",
"Brot & Getreide": "carb", "Brot & Getreide": "carb",
"Milchprodukt": "dairy", "Milchprodukt": "dairy",
"Obst": "fruit", "Obst": "fruit",
+71 -3
View File
@@ -10,6 +10,8 @@ from werkzeug.security import generate_password_hash
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
CURRENT_SCHEMA_VERSION = "0.6.0"
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
if "db" not in g: 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}") 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: def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
ensure_meta_table(database)
database.execute( database.execute(
""" """
CREATE TABLE IF NOT EXISTS households ( 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: def sync_default_categories(database: sqlite3.Connection) -> None:
for household_id in household_ids(database): 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): for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute( database.execute(
""" """
@@ -230,6 +296,7 @@ def sync_default_categories(database: sqlite3.Connection) -> None:
def ensure_schema_upgrades(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", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "email TEXT")
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") 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") add_column_if_missing(database, "users", "updated_at TEXT")
default_household_id = ensure_default_household(database) default_household_id = ensure_default_household(database)
database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)") database.execute("UPDATE households SET shopping_weekday = 5 WHERE shopping_weekday IS NULL")
database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)") database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL")
database.execute( 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( database.execute(
"UPDATE users SET household_id = ? WHERE household_id IS NULL", "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) ON shopping_needs (household_id, activation_date, is_activated)
""" """
) )
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
def apply_schema(database: sqlite3.Connection) -> None: def apply_schema(database: sqlite3.Connection) -> None:
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
try:
from PIL import Image, ImageOps, UnidentifiedImageError
PILLOW_AVAILABLE = True
except ImportError: # pragma: no cover - local fallback when Pillow is unavailable
Image = None
ImageOps = None
UnidentifiedImageError = OSError
PILLOW_AVAILABLE = False
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:
if not PILLOW_AVAILABLE or Image is None or ImageOps is None:
raise OSError("Pillow ist nicht verfügbar.")
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 not PILLOW_AVAILABLE:
return 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
+266 -59
View File
@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
import uuid
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from itertools import product
from pathlib import Path from pathlib import Path
from flask import ( from flask import (
Blueprint, Blueprint,
after_this_request,
current_app, current_app,
flash, flash,
g, g,
@@ -14,11 +15,12 @@ from flask import (
redirect, redirect,
render_template, render_template,
request, request,
send_file,
url_for, 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 ( from .constants import (
AVAILABILITY_LABELS, AVAILABILITY_LABELS,
BUILDER_LABELS, BUILDER_LABELS,
@@ -35,12 +37,15 @@ from .constants import (
WEEK_TEMPLATE_NAME_SUGGESTIONS, WEEK_TEMPLATE_NAME_SUGGESTIONS,
) )
from .db import get_db 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 from .push import push_is_configured, push_public_key, send_push_message
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
ACTIVE_STATE_OPTIONS = [ ACTIVE_STATE_OPTIONS = [
("", "Alle aktiven"), ("", "Alle aktiven"),
("home", "Zuhause"), ("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 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: def save_photo(upload, current_filename: str | None = None) -> str | None:
if not upload or not upload.filename: if not upload or not upload.filename:
return current_filename return current_filename
if not allowed_image_file(upload.filename):
if not allowed_file(upload.filename):
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]):
original_name = secure_filename(upload.filename) raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.")
extension = original_name.rsplit(".", 1)[1].lower() return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename)
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 user_display_name(display_name: str | None, username: str | None) -> str: 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]) return ", ".join(item["name"] for item in items[:limit])
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: def item_matches_daypart(item: dict, daypart_id: int | None) -> bool:
home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id) if daypart_id is None:
home_food_ids = {item["id"] for item in home_foods} 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) builder_groups: dict[str, list[dict]] = defaultdict(list)
for food in home_foods: for food in home_foods:
for builder_key in food.get("builder_keys", ["neutral"]): for builder_key in food.get("builder_keys", ["neutral"]):
builder_groups[builder_key].append(food) 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] = [] 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: for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): 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( suggestions.append(
{ {
"title": meal["name"], "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 "") 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"}: for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
if builder_groups["carb"] and builder_groups["dairy"]: signature = normalized_component_signature(suggestion["component_ids"])
combo = [builder_groups["carb"][0], builder_groups["dairy"][0]] if signature in seen_signatures:
if builder_groups["fruit"]: continue
combo.append(builder_groups["fruit"][0]) seen_signatures.add(signature)
if builder_groups["nuts"]: suggestions.append(suggestion)
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,
}
)
deduped: list[dict] = [] deduped: list[dict] = []
seen = set() seen = set()
@@ -1336,6 +1384,77 @@ def build_dashboard_hints(today: date) -> list[str]:
return hints[:4] 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]: def build_day_hints(selected_date: date) -> list[str]:
settings = get_user_settings() settings = get_user_settings()
if not settings.get("reminders_enabled"): if not settings.get("reminders_enabled"):
@@ -1975,6 +2094,28 @@ def create_or_get_generated_meal(
daypart_id: int, daypart_id: int,
visibility: str, visibility: str,
) -> int: ) -> 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( existing = get_db().execute(
f""" f"""
SELECT items.id SELECT items.id
@@ -1986,7 +2127,21 @@ def create_or_get_generated_meal(
[name, *visible_params()], [name, *visible_params()],
).fetchone() ).fetchone()
if existing: 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( cursor = get_db().execute(
""" """
@@ -2000,14 +2155,14 @@ def create_or_get_generated_meal(
g.user["id"], g.user["id"],
visibility, visibility,
name, 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"],
g.user["id"], g.user["id"],
), ),
) )
meal_id = int(cursor.lastrowid) meal_id = int(cursor.lastrowid)
sync_item_dayparts(meal_id, [daypart_id]) 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() get_db().commit()
return meal_id return meal_id
@@ -2074,6 +2229,7 @@ def dashboard():
upcoming_entries=fetch_upcoming_shopping_needs(limit=4), upcoming_entries=fetch_upcoming_shopping_needs(limit=4),
day_templates=fetch_day_templates()[:3], day_templates=fetch_day_templates()[:3],
week_templates=fetch_week_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_subscription_count=int(push_subscription["count"]),
push_ready=push_is_configured(), push_ready=push_is_configured(),
push_public_key_value=push_public_key(), 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") @main_bp.post("/push/subscribe")
@login_required @login_required
def push_subscribe(): def push_subscribe():
+6
View File
@@ -1,5 +1,11 @@
PRAGMA foreign_keys = ON; 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 ( CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
+5 -15
View File
@@ -1,16 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs> <rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
<stop offset="0%" stop-color="#ffd7be"/> <path d="M31.9997 19C36.9702 19 40.9997 23.0781 40.9997 28.1081V29.3848C40.9997 32.7262 39.6098 35.9158 37.1535 38.1417L29.9949 44.6282H24.5605L32.4863 37.4468C34.0025 36.0726 34.8571 34.1103 34.8571 32.0653V28.1081C34.8571 26.551 33.5671 25.2162 31.9997 25.2162C30.4324 25.2162 29.1424 26.551 29.1424 28.1081V29.3848H23C23 23.0781 27.0295 19 31.9997 19Z" fill="#8C533B"/>
<stop offset="100%" stop-color="#e39a63"/> <rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
</linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b5dfc8"/>
<stop offset="100%" stop-color="#72a98b"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 715 B

+15 -16
View File
@@ -1,21 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title"> <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Nouri</title>
<defs> <defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ffd7be"/> <stop stop-color="#F6C394"/>
<stop offset="55%" stop-color="#f5b17a"/> <stop offset="1" stop-color="#DE9862"/>
<stop offset="100%" stop-color="#d58c57"/>
</linearGradient> </linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#b5dfc8"/> <stop stop-color="#FFF8F0"/>
<stop offset="100%" stop-color="#70aa87"/> <stop offset="1" stop-color="#FDE7D5"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="256" height="256" rx="64" fill="url(#bg)"/> <rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/> <rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/> <path d="M152 232C152 175.667 197.667 130 254 130H258C315.438 130 362 176.562 362 234V242C362 299.438 315.438 346 258 346H254C197.667 346 152 300.333 152 244V232Z" fill="url(#nouriBowl)"/>
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/> <path d="M175 244C175 201.474 209.474 167 252 167H258C300.526 167 335 201.474 335 244V244C335 286.526 300.526 321 258 321H252C209.474 321 175 286.526 175 244V244Z" fill="#EFC39F" fill-opacity="0.22"/>
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/> <path d="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/> <path d="M198 287H314C311.53 314.638 288.347 336 260.2 336H251.8C223.653 336 200.47 314.638 198 287Z" fill="#A86244" fill-opacity="0.2"/>
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/> <path d="M255.999 171C271.513 171 284.246 183.574 284.246 199.27V205.573C284.246 216.441 279.591 226.794 271.487 233.994L246.435 256.256C241.017 261.07 237.906 268.003 237.906 275.283V295.246H212.906V275.283C212.906 260.722 219.129 246.854 229.965 237.227L255.016 214.966C257.705 212.578 259.246 209.146 259.246 205.573V199.27C259.246 197.28 257.651 195.6 255.999 195.6C254.347 195.6 252.752 197.28 252.752 199.27V205.091H227.752V199.27C227.752 183.574 240.485 171 255.999 171Z" fill="#8C533B"/>
<rect x="226" y="280" width="63" height="25" rx="12.5" fill="#8C533B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+178 -33
View File
@@ -103,6 +103,17 @@ button,
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; 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,
.button:hover { .button:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -152,13 +163,11 @@ button.secondary:hover,
} }
.site-header { .site-header {
position: sticky; position: static;
top: 1rem;
z-index: 10; z-index: 10;
display: grid; display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem; gap: 1rem;
align-items: center; align-items: stretch;
padding: 1rem 1.2rem; padding: 1rem 1.2rem;
margin-bottom: 1.15rem; margin-bottom: 1.15rem;
background: var(--bg-elevated); background: var(--bg-elevated);
@@ -168,6 +177,11 @@ button.secondary:hover,
backdrop-filter: blur(26px) saturate(1.18); backdrop-filter: blur(26px) saturate(1.18);
} }
.desktop-header-main,
.desktop-header-sub {
min-width: 0;
}
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -213,7 +227,7 @@ h3,
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; gap: 0.45rem;
justify-content: center; justify-content: flex-start;
min-width: 0; min-width: 0;
} }
@@ -241,6 +255,37 @@ h3,
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
}
@media (min-width: 1081px) {
.site-header {
grid-template-columns: 1fr;
row-gap: 0.9rem;
}
.desktop-header-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.4rem;
}
.desktop-header-sub {
display: flex;
justify-content: flex-start;
}
.desktop-nav {
width: 100%;
justify-content: flex-start;
}
.desktop-actions {
width: 100%;
justify-content: flex-start;
align-self: center;
}
} }
.user-chip, .user-chip,
@@ -328,6 +373,12 @@ h3,
linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%)); 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 { .eyebrow {
margin: 0 0 0.45rem; margin: 0 0 0.45rem;
text-transform: uppercase; text-transform: uppercase;
@@ -370,6 +421,10 @@ h3 {
line-height: 1.6; line-height: 1.6;
} }
.empty-panel {
text-align: left;
}
.intro-pills, .intro-pills,
.chip-row { .chip-row {
display: flex; display: flex;
@@ -599,6 +654,21 @@ h3 {
width: min(560px, 100%); 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-form,
.stack-sections, .stack-sections,
.planner-day-stack, .planner-day-stack,
@@ -715,6 +785,7 @@ legend {
width: min(220px, 100%); width: min(220px, 100%);
border-radius: 18px; border-radius: 18px;
border: 1px solid var(--line); border: 1px solid var(--line);
box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12);
} }
.compact-form-panel { .compact-form-panel {
@@ -948,6 +1019,12 @@ legend {
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); 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 { .card-link-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem; gap: 0.75rem;
@@ -969,6 +1046,16 @@ legend {
color: var(--accent-strong); color: var(--accent-strong);
} }
.menu-card-button {
width: 100%;
cursor: pointer;
font: inherit;
}
.menu-card-form {
margin: 0;
}
.roomy-row { .roomy-row {
padding: 1rem 1.2rem; padding: 1rem 1.2rem;
} }
@@ -1191,6 +1278,10 @@ legend {
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.mobile-nav-stack {
display: none;
}
.mobile-more-sheet { .mobile-more-sheet {
position: fixed; position: fixed;
left: 0.75rem; left: 0.75rem;
@@ -1223,6 +1314,10 @@ legend {
margin: 1rem 0; margin: 1rem 0;
} }
.mobile-menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mobile-sheet-actions { .mobile-sheet-actions {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -1232,7 +1327,6 @@ legend {
} }
@media (max-width: 1080px) { @media (max-width: 1080px) {
.site-header,
.hero, .hero,
.page-intro, .page-intro,
.panel-head, .panel-head,
@@ -1241,9 +1335,19 @@ legend {
align-items: flex-start; align-items: flex-start;
} }
body.has-mobile-nav {
padding-top: 0;
}
.site-header { .site-header {
position: static; position: static;
grid-template-columns: 1fr; width: 100%;
margin: 0 0 1.15rem;
}
.desktop-header-main,
.desktop-header-sub {
display: contents;
} }
.stats-grid, .stats-grid,
@@ -1273,12 +1377,10 @@ legend {
} }
.site-header { .site-header {
position: sticky;
top: 0.7rem;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 0.6rem; gap: 0.6rem;
padding: 0.75rem 0.9rem; padding: 0.75rem 0.9rem;
margin-bottom: 0.85rem; margin-bottom: 1rem;
border-radius: 22px; border-radius: 22px;
} }
@@ -1343,6 +1445,10 @@ legend {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.setup-intro-grid {
grid-template-columns: 1fr;
}
.item-card { .item-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1377,54 +1483,95 @@ legend {
min-width: 100%; min-width: 100%;
} }
.mobile-bottom-nav { .mobile-nav-stack {
position: fixed; position: fixed;
left: 0.75rem; left: 0.75rem;
right: 0.75rem; right: 0.75rem;
bottom: 0.75rem; bottom: 0.75rem;
z-index: 20; z-index: 24;
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem; gap: 0.35rem;
padding: 0.5rem; padding: 0.5rem;
border-radius: 22px; border-radius: 22px;
background: var(--bg-elevated); background: color-mix(in srgb, var(--bg) 96%, #f6decb 4%);
border: 1px solid var(--line); border: 1px solid var(--line);
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(26px) saturate(1.15);
} }
.mobile-bottom-nav a { .mobile-nav-extension {
display: none;
}
.mobile-nav-stack.is-open .mobile-nav-extension {
display: grid; display: grid;
justify-items: center;
gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px;
color: var(--muted);
font-size: 0.78rem;
} }
.mobile-nav-extension,
.mobile-sheet-links.mobile-menu-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.35rem;
margin: 0;
}
.mobile-extra-link,
.mobile-extra-button,
.mobile-bottom-nav a,
.mobile-nav-button { .mobile-nav-button {
display: grid;
justify-items: center; justify-items: center;
align-content: center;
display: grid;
min-height: 3.95rem;
padding: 0.55rem 0.2rem 0.5rem;
text-align: center;
gap: 0.28rem; gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px; border-radius: 16px;
border: 0;
background: transparent; background: transparent;
box-shadow: none;
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
border: 0;
} }
.mobile-bottom-nav a.active, .mobile-extra-link .ui-icon,
.mobile-nav-button.is-open { .mobile-extra-button .ui-icon,
background: var(--accent-soft);
color: var(--text);
}
.mobile-bottom-nav .ui-icon { .mobile-bottom-nav .ui-icon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
margin-top: 0;
}
.mobile-extra-link span:last-child,
.mobile-extra-button span:last-child,
.mobile-bottom-nav a span:last-child,
.mobile-nav-button span:last-child {
font-size: 0.72rem;
line-height: 1.08;
}
.mobile-extra-form {
display: contents;
}
.mobile-bottom-nav {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
padding: 0;
}
.mobile-bottom-nav a {
}
.mobile-nav-button {
cursor: pointer;
font: inherit;
}
.mobile-bottom-nav a.active,
.mobile-extra-link.active,
.mobile-nav-button.is-open {
background: var(--accent-soft);
color: var(--text);
} }
.mobile-profile-link { .mobile-profile-link {
@@ -1438,8 +1585,6 @@ legend {
height: 2.15rem; height: 2.15rem;
} }
.mobile-sheet-head,
.mobile-sheet-actions,
.week-template-row { .week-template-row {
align-items: flex-start; align-items: flex-start;
} }
+63 -14
View File
@@ -1,32 +1,33 @@
(() => { (() => {
const initMobileSheet = () => { const initMobileSheet = () => {
const sheet = document.querySelector("[data-mobile-sheet]"); const sheet = document.querySelector("[data-mobile-sheet]");
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]"); const navStack = document.querySelector("[data-mobile-nav-stack]");
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]"); const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]"); if (!sheet || !navStack || !openButtons.length) return;
if (!sheet || !backdrop || !openButtons.length) return;
const closeSheet = () => { const closeSheet = () => {
sheet.hidden = true; sheet.hidden = true;
backdrop.hidden = true; navStack.classList.remove("is-open");
document.body.classList.remove("sheet-open");
openButtons.forEach((button) => button.classList.remove("is-open")); openButtons.forEach((button) => button.classList.remove("is-open"));
}; };
const openSheet = () => { const openSheet = () => {
sheet.hidden = false; sheet.hidden = false;
backdrop.hidden = false; navStack.classList.add("is-open");
document.body.classList.add("sheet-open");
openButtons.forEach((button) => button.classList.add("is-open")); openButtons.forEach((button) => button.classList.add("is-open"));
}; };
const toggleSheet = () => {
if (sheet.hidden) {
openSheet();
} else {
closeSheet();
}
};
openButtons.forEach((button) => { openButtons.forEach((button) => {
button.addEventListener("click", openSheet); button.addEventListener("click", toggleSheet);
}); });
closeButtons.forEach((button) => {
button.addEventListener("click", closeSheet);
});
backdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -37,6 +38,9 @@
sheet.querySelectorAll("a").forEach((link) => { sheet.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeSheet); link.addEventListener("click", closeSheet);
}); });
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", closeSheet);
});
}; };
const initFilterInputs = () => { const initFilterInputs = () => {
@@ -47,12 +51,57 @@
if (!container) return; if (!container) return;
const items = Array.from(container.querySelectorAll("[data-filter-label]")); 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 applyFilter = () => {
const term = input.value.trim().toLowerCase(); 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) => { items.forEach((item) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); item.hidden = !allowedItems.has(item);
item.hidden = Boolean(term) && !haystack.includes(term);
}); });
syncGroups();
}; };
input.addEventListener("input", applyFilter); input.addEventListener("input", applyFilter);
+14 -1
View File
@@ -6,9 +6,16 @@
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#fff6ef", "background_color": "#fff6ef",
"theme_color": "#efab72", "theme_color": "#de9862",
"categories": ["food", "lifestyle", "productivity"],
"icons": [ "icons": [
{
"src": "/static/brand/pwa-180.png",
"sizes": "180x180",
"type": "image/png"
},
{ {
"src": "/static/brand/pwa-192.png", "src": "/static/brand/pwa-192.png",
"sizes": "192x192", "sizes": "192x192",
@@ -18,6 +25,12 @@
"src": "/static/brand/pwa-512.png", "src": "/static/brand/pwa-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
},
{
"src": "/static/brand/pwa-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nouri offline</title>
<style>
:root {
color-scheme: light;
--bg: #fff6ef;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(126, 104, 85, 0.14);
--text: #352d2b;
--muted: #7d7069;
--accent: #de9862;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
linear-gradient(180deg, var(--bg), #fdf0e6);
}
.card {
width: min(28rem, 100%);
padding: 1.4rem;
border-radius: 24px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 22px 48px rgba(125, 92, 68, 0.12);
}
h1 {
margin: 0 0 0.45rem;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
font-size: 2rem;
}
p {
margin: 0.35rem 0;
line-height: 1.55;
}
.muted {
color: var(--muted);
}
a {
display: inline-block;
margin-top: 1rem;
padding: 0.82rem 1.1rem;
border-radius: 999px;
background: var(--accent);
color: white;
text-decoration: none;
}
</style>
</head>
<body>
<main class="card">
<h1>Nouri ist gerade kurz offline</h1>
<p>Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.</p>
<p class="muted">Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.</p>
<a href="/">Erneut versuchen</a>
</main>
</body>
</html>
+47 -16
View File
@@ -1,19 +1,35 @@
const CACHE_NAME = "nouri-v0-5-0"; const CACHE_NAME = "nouri-v0-6-0";
const APP_SHELL = [ const OFFLINE_URL = "/static/pwa/offline.html";
"/", const STATIC_ASSETS = [
"/static/css/styles.css", "/static/css/styles.css",
"/static/js/theme.js", "/static/js/theme.js",
"/static/js/ui.js", "/static/js/ui.js",
"/static/js/planner.js", "/static/js/planner.js",
"/static/js/pwa.js", "/static/js/pwa.js",
"/static/brand/pwa-180.png",
"/static/brand/pwa-192.png", "/static/brand/pwa-192.png",
"/static/brand/pwa-512.png", "/static/brand/pwa-512.png",
"/static/brand/pwa-maskable-512.png",
"/static/brand/pwa-badge.png",
"/static/brand/favicon.svg", "/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) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting()) caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
); );
}); });
@@ -27,19 +43,34 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cached) => { const requestUrl = new URL(event.request.url);
if (cached) return cached; const isSameOrigin = requestUrl.origin === self.location.origin;
return fetch(event.request).then((response) => { const isStaticAsset = isSameOrigin && (
if (!response || response.status !== 200 || response.type !== "basic") { requestUrl.pathname.startsWith("/static/")
return response; || requestUrl.pathname === "/app.webmanifest"
} || requestUrl.pathname === "/service-worker.js"
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
); );
const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/");
if (event.request.mode === "navigate") {
event.respondWith(
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 || isUpload) {
event.respondWith(cacheFirst(event.request));
}
}); });
self.addEventListener("push", (event) => { self.addEventListener("push", (event) => {
+6 -1
View File
@@ -44,7 +44,12 @@
<article class="item-card"> <article class="item-card">
<div class="item-media"> <div class="item-media">
{% if item.photo_filename %} {% if item.photo_filename %}
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}"> <img
src="{{ image_url(item.photo_filename, 'md') }}"
srcset="{{ image_srcset(item.photo_filename) }}"
sizes="{{ image_sizes('grid') }}"
alt="{{ item.name }}"
loading="lazy">
{% else %} {% else %}
<div class="placeholder-tile">{{ item.name[:1] }}</div> <div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %} {% endif %}
+16 -1
View File
@@ -5,7 +5,22 @@
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">Erster Start</p> <p class="eyebrow">Erster Start</p>
<h1>Den ersten Haushalt-Zugang anlegen</h1> <h1>Den ersten Haushalt-Zugang anlegen</h1>
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</p> <p class="lead">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.</p>
<div class="setup-intro-grid">
<div class="setup-tip">
<strong>1. Ruhig starten</strong>
<p class="muted">Ein erster Haushalt und ein Admin-Zugang reichen für den Anfang völlig.</p>
</div>
<div class="setup-tip">
<strong>2. Alltag festhalten</strong>
<p class="muted">Später könnt ihr Lebensmittel, Mahlzeitenideen und Planungen gemeinsam nutzen.</p>
</div>
<div class="setup-tip">
<strong>3. Als App nutzen</strong>
<p class="muted">Auf dem iPhone lässt sich Nouri später über Safari zum Home-Bildschirm hinzufügen.</p>
</div>
</div>
<form method="post" class="stack-form"> <form method="post" class="stack-form">
{{ csrf_input() }} {{ csrf_input() }}
+95 -91
View File
@@ -4,61 +4,69 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Nouri{% endblock %}</title> <title>{% block title %}Nouri{% endblock %}</title>
<meta name="theme-color" content="#efab72"> <meta name="theme-color" content="#de9862">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Nouri"> <meta name="apple-mobile-web-app-title" content="Nouri">
<meta name="application-name" content="Nouri">
<meta name="csrf-token" content="{{ csrf_token_value }}"> <meta name="csrf-token" content="{{ csrf_token_value }}">
<meta name="nouri-push-public-key" content="{{ push_public_key }}"> <meta name="nouri-push-public-key" content="{{ push_public_key }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ asset_url('brand/favicon.svg') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/pwa-192.png') }}"> <link rel="apple-touch-icon" sizes="180x180" href="{{ asset_url('brand/pwa-180.png') }}">
<link rel="manifest" href="{{ url_for('webmanifest') }}"> <link rel="manifest" href="{{ url_for('webmanifest') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ asset_url('css/styles.css') }}">
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script> <script defer src="{{ asset_url('js/theme.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script> <script defer src="{{ asset_url('js/planner.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script> <script defer src="{{ asset_url('js/ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/pwa.js') }}"></script> <script defer src="{{ asset_url('js/pwa.js') }}"></script>
</head> </head>
<body class="{% if g.user %}has-mobile-nav{% endif %}"> <body class="{% if g.user %}has-mobile-nav{% endif %}">
<div class="page-shell"> <div class="page-shell">
<header class="site-header"> <header class="site-header">
<a class="brand" href="{{ url_for('main.dashboard') }}"> <div class="desktop-header-main">
<span class="brand-mark"> <a class="brand" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt=""> <span class="brand-mark">
</span> <img src="{{ asset_url('brand/nouri-icon.svg') }}" alt="">
<span class="brand-copy"> </span>
<strong>Nouri</strong> <span class="brand-copy">
<small>einfach essen planen</small> <strong>Nouri</strong>
</span> <small>einfach essen planen</small>
</a> </span>
</a>
{% if g.user %}
<nav class="site-nav desktop-nav">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
</nav>
{% endif %}
</div>
{% if g.user %} {% if g.user %}
<nav class="site-nav desktop-nav"> <div class="desktop-header-sub">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a> <div class="header-actions desktop-actions">
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a> <button class="theme-toggle ghost-button" type="button" data-theme-toggle>Hell</button>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a> <a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a> <a class="user-chip" href="{{ url_for('auth.profile') }}">
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a> <span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
<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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a> <small>{{ role_labels[g.user.role] }}</small>
<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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a> </a>
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a> {% if g.user.role == 'admin' %}
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a> <a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
</nav> {% endif %}
<form method="post" action="{{ url_for('auth.logout') }}">
<div class="header-actions desktop-actions"> {{ csrf_input() }}
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button> <button class="ghost-button" type="submit">Abmelden</button>
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a> </form>
<a class="user-chip" href="{{ url_for('auth.profile') }}"> </div>
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
<small>{{ role_labels[g.user.role] }}</small>
</a>
{% if g.user.role == 'admin' %}
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div> </div>
<button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen"> <button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
@@ -93,59 +101,55 @@
</div> </div>
{% if g.user %} {% if g.user %}
<div class="mobile-sheet-backdrop" data-mobile-sheet-backdrop hidden></div> <div class="mobile-nav-stack" data-mobile-nav-stack>
<aside class="mobile-more-sheet" data-mobile-sheet hidden aria-label="Mehr"> <nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
<div class="mobile-sheet-head"> <a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<div> <a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<strong>{{ g.user.display_name or g.user.username }}</strong> <a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<small>{{ role_labels[g.user.role] }}</small> <a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
</div> <a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button> <a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
</div> <a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
<nav class="mobile-sheet-links card-link-grid">
<a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
<a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
{% if g.user.role == 'admin' %} {% if g.user.role == 'admin' %}
<a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a> <a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a> <a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
{% endif %} {% endif %}
</nav> <button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle>
<div class="mobile-sheet-actions"> <span class="ui-icon icon-mobile-screen-button"></span>
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button> <span>Modus</span>
<form method="post" action="{{ url_for('auth.logout') }}"> </button>
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button> <button class="mobile-extra-link mobile-extra-button" type="submit">
<span class="ui-icon icon-ellipsis"></span>
<span>Abmelden</span>
</button>
</form> </form>
</div> </nav>
</aside>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation"> <nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"> <a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span> <span class="ui-icon icon-sparkles"></span>
<span>Heute</span> <span>Heute</span>
</a> </a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"> <a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
<span class="ui-icon icon-cart-shopping"></span> <span class="ui-icon icon-cart-shopping"></span>
<span>Einkauf</span> <span>Einkauf</span>
</a> </a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"> <a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
<span class="ui-icon icon-calendar"></span> <span class="ui-icon icon-calendar"></span>
<span>Plan</span> <span>Plan</span>
</a> </a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"> <a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-calendar-days"></span> <span class="ui-icon icon-calendar-days"></span>
<span>Woche</span> <span>Woche</span>
</a> </a>
<button type="button" class="mobile-nav-button" data-mobile-sheet-open> <button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-ellipsis"></span> <span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span> <span>Mehr</span>
</button> </button>
</nav> </nav>
</div>
{% endif %} {% endif %}
</body> </body>
</html> </html>
+17
View File
@@ -13,6 +13,23 @@
</div> </div>
</section> </section>
{% if setup_checklist %}
<section class="panel">
<div class="panel-head">
<h2>Gut anfangen</h2>
</div>
<div class="more-link-grid">
{% for step in setup_checklist %}
<a class="more-link-card" href="{{ step.url }}">
<strong>{{ step.title }}</strong>
<small>{{ step.text }}</small>
<span class="chip">{{ step.label }}</span>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<section class="stats-grid"> <section class="stats-grid">
<article class="stat-card"> <article class="stat-card">
<span>Zuhause</span> <span>Zuhause</span>
+6 -1
View File
@@ -72,7 +72,12 @@
<article class="item-card compact"> <article class="item-card compact">
<div class="item-media"> <div class="item-media">
{% if item.photo_filename %} {% if item.photo_filename %}
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}"> <img
src="{{ image_url(item.photo_filename, 'md') }}"
srcset="{{ image_srcset(item.photo_filename) }}"
sizes="{{ image_sizes('grid') }}"
alt="{{ item.name }}"
loading="lazy">
{% else %} {% else %}
<div class="placeholder-tile">{{ item.name[:1] }}</div> <div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %} {% endif %}
+8 -1
View File
@@ -67,7 +67,12 @@
{% if item and item.photo_filename %} {% if item and item.photo_filename %}
<div class="inline-photo"> <div class="inline-photo">
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}"> <img
src="{{ image_url(item.photo_filename, 'lg') }}"
srcset="{{ image_srcset(item.photo_filename) }}"
sizes="{{ image_sizes('detail') }}"
alt="{{ item.name }}"
loading="lazy">
</div> </div>
{% endif %} {% endif %}
@@ -97,10 +102,12 @@
placeholder="z. B. Reis, Banane, Joghurt" placeholder="z. B. Reis, Banane, Joghurt"
data-filter-input data-filter-input
data-filter-target="#meal-components-list" data-filter-target="#meal-components-list"
data-filter-limit="3"
> >
</label> </label>
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button> <button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
</div> </div>
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
{% if food_groups %} {% if food_groups %}
<div class="stack-sections" id="meal-components-list"> <div class="stack-sections" id="meal-components-list">
{% for group in food_groups %} {% for group in food_groups %}
+6 -1
View File
@@ -54,7 +54,12 @@
<article class="item-card"> <article class="item-card">
<div class="item-media"> <div class="item-media">
{% if item.photo_filename %} {% if item.photo_filename %}
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}"> <img
src="{{ image_url(item.photo_filename, 'md') }}"
srcset="{{ image_srcset(item.photo_filename) }}"
sizes="{{ image_sizes('grid') }}"
alt="{{ item.name }}"
loading="lazy">
{% else %} {% else %}
<div class="placeholder-tile">{{ item.name[:1] }}</div> <div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %} {% endif %}
+58 -8
View File
@@ -4,8 +4,8 @@
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Optionen</p> <p class="eyebrow">Optionen</p>
<h1>Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen</h1> <h1>Ruhige Einstellungen für Alltag, Sicherung und iPhone-Nutzung</h1>
<p class="lead">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.</p> <p class="lead">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.</p>
</div> </div>
</section> </section>
@@ -33,23 +33,29 @@
Erinnerung ungefähr um Erinnerung ungefähr um
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}"> <input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
</label> </label>
<button type="submit">Speichern</button> <div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form> </form>
</article> </article>
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Home-Bildschirm & Push</h2> <h2>Für den Homescreen</h2>
</div> </div>
<div class="stack-sections"> <div class="stack-sections">
<div class="pwa-card"> <div class="pwa-card">
<strong>Als Web-App nutzen</strong> <strong>Auf dem iPhone installieren</strong>
<p class="muted">Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.</p> <p class="muted">Öffne Nouri in Safari, tippe auf Teilen und dann auf <em>Zum Home-Bildschirm</em>. Danach startet Nouri deutlich app-näher und ruhiger.</p>
</div>
<div class="pwa-card">
<strong>Offline etwas stabiler</strong>
<p class="muted">Die wichtigsten Oberflächen und Brand-Dateien bleiben lokal greifbar. Wenn das Netz kurz weg ist, wirkt die App dadurch stabiler und klarer.</p>
</div> </div>
<div class="pwa-card"> <div class="pwa-card">
<strong>Push-Mitteilungen</strong> <strong>Push-Mitteilungen</strong>
{% if push_ready %} {% if push_ready %}
<p class="muted">Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.</p> <p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben und mit einer Test-Mitteilung prüfen.</p>
<div class="row-actions"> <div class="row-actions">
<button class="secondary" type="button" data-push-enable>Push erlauben</button> <button class="secondary" type="button" data-push-enable>Push erlauben</button>
<button class="ghost-button" type="button" data-push-disable>Push beenden</button> <button class="ghost-button" type="button" data-push-disable>Push beenden</button>
@@ -61,7 +67,10 @@
</form> </form>
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small> <small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
{% else %} {% else %}
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.</p> <p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für diese App gesetzt sind.</p>
{% if push_public_key_value %}
<small class="helper-text">Öffentlicher Schlüssel erkannt, privater Schlüssel fehlt noch.</small>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -122,4 +131,45 @@
</div> </div>
</form> </form>
</section> </section>
{% if is_admin() %}
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Backup exportieren</h2>
</div>
<div class="stack-sections">
<div class="pwa-card">
<strong>Komplettes App-Backup</strong>
<p class="muted">Das ZIP enthält Nutzer, Einstellungen, Lebensmittel, Mahlzeiten, Vorlagen, Planungen, Einkaufsdaten und hochgeladene Bilder.</p>
<a class="button" href="{{ url_for('main.backup_export') }}">Backup herunterladen</a>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<h2>Backup wiederherstellen</h2>
</div>
<form method="post" action="{{ url_for('main.backup_restore') }}" class="stack-form" enctype="multipart/form-data">
{{ csrf_input() }}
<div class="restore-warning">
<strong>Nur bewusst verwenden</strong>
<p class="muted">Die Wiederherstellung ersetzt den aktuellen Datenstand dieses Haushalts. Vorher am besten selbst noch ein frisches Backup herunterladen.</p>
</div>
<label>
Backup-Datei
<input type="file" name="backup_file" accept=".zip" required>
</label>
<label>
Zur Bestätigung bitte {{ restore_confirmation_text }} eintragen
<input type="text" name="restore_confirmation" placeholder="{{ restore_confirmation_text }}" required>
</label>
<div class="form-actions">
<button type="submit">Backup wiederherstellen</button>
</div>
</form>
</article>
</section>
{% endif %}
{% endblock %} {% endblock %}
+1
View File
@@ -1,3 +1,4 @@
Flask==3.1.1 Flask==3.1.1
gunicorn==23.0.0 gunicorn==23.0.0
pywebpush==2.3.0 pywebpush==2.3.0
Pillow==11.2.1; python_version < "3.14"
+106
View File
@@ -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()
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from py_vapid import Vapid01, b64urlencode
from cryptography.hazmat.primitives import serialization
def main() -> None:
vapid = Vapid01()
vapid.generate_keys()
public_key = b64urlencode(
vapid.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
private_value = vapid.private_key.private_numbers().private_value
private_key = b64urlencode(private_value.to_bytes(32, "big"))
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
if __name__ == "__main__":
main()
+13 -11
View File
@@ -1,14 +1,16 @@
#!/bin/sh #!/bin/bash
set -eu set -eu
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}" mkdir -p /app/data/uploads
mkdir -p "${NOURI_DATA_DIR}"
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
mkdir -p "${NOURI_DATA_DIR}/uploads"
exec gunicorn \ # Vorhandene lokale SQLite-Datei beim allerersten Start übernehmen
--bind 0.0.0.0:8000 \ if [ ! -f /app/data/nouri.sqlite3 ] && [ -f /app/bootstrap-data/nouri.sqlite3 ]; then
--workers 2 \ cp /app/bootstrap-data/nouri.sqlite3 /app/data/nouri.sqlite3
--threads 4 \ fi
--timeout 60 \
wsgi:app # Vorhandene Uploads beim allerersten Start übernehmen
if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/null || true)" ]; then
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
fi
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app