3 Commits

Author SHA1 Message Date
hnzio 1839328dfe Harden local secrets and backup restore 2026-06-05 16:23:38 +02:00
hnzio 01b8128835 Add Home Assistant shopping API 2026-05-03 14:43:00 +02:00
hnzio 4babf93179 Release Nouri 1.3.4 with quieter meal cards 2026-05-01 14:38:54 +02:00
16 changed files with 543 additions and 35 deletions
+4
View File
@@ -12,3 +12,7 @@ dist
build build
data data
instance instance
.cloudron-push.env
.env.local
.env.push.local
nouri.sqlite3
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
data/ data/
instance/ instance/
nouri.sqlite3
.cloudron-push.env .cloudron-push.env
.env.local .env.local
.env.push.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": "1.3.3", "version": "1.3.4",
"upstreamVersion": "1.3.3", "upstreamVersion": "1.3.4",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+210
View File
@@ -0,0 +1,210 @@
# Home Assistant
Nouri stellt für Home Assistant eine kleine token-geschützte JSON-API bereit. Sie ist für Sprachbefehle wie diese gedacht:
- `Füge Erbsen auf die Einkaufsliste hinzu`
- `Füge Erbsen mit Hinweis TK auf die Einkaufsliste hinzu`
- `Füge Blumenerde als Einkaufsartikel hinzu`
- `Füge Skyr als Lebensmittel hinzu`
## Nouri konfigurieren
Setze in der Nouri-Umgebung:
```bash
NOURI_HA_TOKEN=<langes-zufälliges-token>
NOURI_HA_USER_ID=<optionale-nouri-user-id>
```
`NOURI_HA_USER_ID` ist optional. Ohne diesen Wert nutzt Nouri zuerst den ersten aktiven Admin und danach den ersten aktiven Nutzer.
Lokal zum Testen:
```bash
export NOURI_HA_TOKEN='ein-langes-zufälliges-token'
flask --app wsgi run --debug
```
Auf Cloudron:
```bash
cloudron env set --app <nouri-app> NOURI_HA_TOKEN='ein-langes-zufälliges-token'
cloudron restart --app <nouri-app>
```
## Home Assistant vorbereiten
Lege in Home Assistant in `secrets.yaml` diese Werte an:
```yaml
nouri_ha_token: ein-langes-zufälliges-token
nouri_url: https://deine-nouri-domain.example.org
```
Wenn Home Assistant lokal auf Nouri zugreift, nimm die aus Home Assistant erreichbare Adresse, zum Beispiel:
```yaml
nouri_url: http://192.168.178.50:5000
```
`127.0.0.1` funktioniert nur, wenn Home Assistant und Nouri im selben Prozess- oder Container-Kontext laufen.
## Eintrag hinzufügen
```http
POST /api/home-assistant/shopping
Authorization: Bearer <NOURI_HA_TOKEN>
Content-Type: application/json
```
```json
{
"name": "Erbsen",
"note": "TK"
}
```
Wenn Nouri den Eintrag kennt, wird er direkt auf die Einkaufsliste gesetzt. `note` landet im bestehenden Feld `Einkaufshinweis`.
Wenn Nouri den Eintrag nicht kennt, kommt eine Rückfrage-Antwort:
```json
{
"ok": true,
"status": "needs_confirmation",
"name": "Erbsen",
"note": "TK",
"options": ["food", "shopping"],
"message": "Erbsen kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?"
}
```
## Rückfrage bestätigen
```http
POST /api/home-assistant/shopping/confirm
Authorization: Bearer <NOURI_HA_TOKEN>
Content-Type: application/json
```
```json
{
"name": "Erbsen",
"note": "TK",
"create_as": "food"
}
```
Mögliche Werte für `create_as`:
- `food` für Lebensmittel
- `shopping` für reine Einkaufsartikel
Alternativ kann der erste Endpoint mit `confirm_create: true` verwendet werden:
```json
{
"name": "Blumenerde",
"create_as": "shopping",
"confirm_create": true
}
```
## REST Commands
Trage in `configuration.yaml` ein:
```yaml
rest_command:
nouri_shopping:
url: "{{ url }}/api/home-assistant/shopping"
method: post
headers:
X-Nouri-Token: !secret nouri_ha_token
Content-Type: "application/json"
payload: >
{
"name": "{{ name }}",
"note": "{{ note | default('') }}",
"create_as": "{{ create_as | default('') }}",
"confirm_create": {{ confirm_create | default(false) | tojson }}
}
nouri_shopping_confirm:
url: "{{ url }}/api/home-assistant/shopping/confirm"
method: post
headers:
X-Nouri-Token: !secret nouri_ha_token
Content-Type: "application/json"
payload: >
{
"name": "{{ name }}",
"note": "{{ note | default('') }}",
"create_as": "{{ create_as }}"
}
```
Danach Home Assistant neu starten oder die YAML-Konfiguration neu laden.
## Manuell testen
In Home Assistant:
1. Öffne `Entwicklerwerkzeuge`.
2. Öffne `Aktionen`.
3. Wähle `rest_command.nouri_shopping`.
4. Nutze zum Testen:
```yaml
url: !secret nouri_url
name: Erbsen
note: TK
```
Wenn `Erbsen` in Nouri existiert, wird es auf die Einkaufsliste gesetzt. Wenn nicht, antwortet Nouri mit `needs_confirmation`.
## Assist-Sprachsätze
Minimal in `configuration.yaml`:
```yaml
conversation:
intents:
NouriShopping:
- "füge {name} auf die einkaufsliste hinzu"
- "füge {name} mit hinweis {note} auf die einkaufsliste hinzu"
- "füge {name} als lebensmittel hinzu"
- "füge {name} als einkaufsartikel hinzu"
```
Dazu ein Intent Script:
```yaml
intent_script:
NouriShopping:
action:
- action: rest_command.nouri_shopping
data:
url: !secret nouri_url
name: "{{ name }}"
note: "{{ note | default('') }}"
create_as: >
{% if 'lebensmittel' in text | default('') | lower %}food{% elif 'einkaufsartikel' in text | default('') | lower %}shopping{% else %}{% endif %}
confirm_create: false
response_variable: nouri_response
- stop: "Nouri Antwort"
response_variable: nouri_response
speech:
text: >
{{ action_response.content.message }}
```
## Rückfrage-Logik
Der erste Schritt ist damit fertig: bekannte Lebensmittel oder Einkaufsartikel landen direkt auf der Nouri-Einkaufsliste.
Wenn Nouri mit `needs_confirmation` antwortet, muss Home Assistant im nächsten Schritt nachfragen:
> Soll ich das als Lebensmittel oder als Einkaufsartikel anlegen?
Danach ruft Home Assistant `/api/home-assistant/shopping/confirm` mit `create_as: food` oder `create_as: shopping` auf. Diese zweite Stufe kann je nach Setup über Assist, eine Automation, eine mobile Benachrichtigung oder später über Alexa umgesetzt werden.
+4
View File
@@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen:
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen - `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin` - `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
- `NOURI_HA_TOKEN`: optionaler Bearer Token fuer die Home-Assistant-API
- `NOURI_HA_USER_ID`: optionaler Nutzer, unter dem Home Assistant Eintraege anlegt
- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push - `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push - `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io` - `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
@@ -102,6 +104,8 @@ Für eine saubere produktive Installation sind diese Werte sinnvoll:
- `NOURI_SECURE_COOKIES=1` - `NOURI_SECURE_COOKIES=1`
- `NOURI_TIMEZONE=Europe/Berlin` - `NOURI_TIMEZONE=Europe/Berlin`
- `NOURI_SECRET_KEY=<eigenes-secret>` - `NOURI_SECRET_KEY=<eigenes-secret>`
- `NOURI_HA_TOKEN=<eigenes-home-assistant-token>`
- `NOURI_HA_USER_ID=<optionale-nouri-user-id>`
- `NOURI_VAPID_PUBLIC_KEY=<public-key>` - `NOURI_VAPID_PUBLIC_KEY=<public-key>`
- `NOURI_VAPID_PRIVATE_KEY=<private-key>` - `NOURI_VAPID_PRIVATE_KEY=<private-key>`
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io` - `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
+24
View File
@@ -0,0 +1,24 @@
# Nouri 1.3.4
Nouri 1.3.4 räumt die Mahlzeiten-Übersicht auf und bringt sie optisch näher an die ruhigeren Lebensmittel-Karten. Der Fokus liegt auf weniger sichtbaren Details, klarerem Scannen und dem Namen direkt unter dem Bild.
## Neu in 1.3.4
- Mahlzeitenkarten zeigen auf der Übersichtsseite jetzt zuerst Bild oder Platzhalter und darunter den Namen.
- Details wie Sichtbarkeit, Zielperson, Mahlzeitentyp, Energie und Tags liegen in einer Hover-Ansicht.
- Komponenten und Notizen werden ebenfalls erst beim Hover bzw. Fokus eingeblendet.
- Die sichtbaren Text-Buttons auf Mahlzeitenkarten wurden durch kompakte Icon-Aktionen ersetzt.
- Archivieren bleibt als kleiner Kreis oben rechts erreichbar.
- Tagesplan und Einkaufsliste sind als schnelle Icon-Aktionen oben links erreichbar.
## Bedienung
- Auf Desktop erscheinen Zusatzinfos beim Mouseover.
- Per Tastatur erscheinen Zusatzinfos, sobald eine Aktion auf der Karte fokussiert ist.
- Auf kleinen Bildschirmen bleibt die Übersicht bewusst reduziert, wie bei den Lebensmittelkarten.
## Betrieb
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.4`.
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.4` angehoben.
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.4`.
View File
+3 -1
View File
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
).strip() ).strip()
if manifest_version: if manifest_version:
return manifest_version return manifest_version
return "1.3.3" return "1.3.4"
def load_release_url() -> str: def load_release_url() -> str:
@@ -125,6 +125,8 @@ def create_app() -> Flask:
APP_VERSION=app_version, APP_VERSION=app_version,
RELEASE_URL=release_url, RELEASE_URL=release_url,
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"), TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
HOME_ASSISTANT_TOKEN=os.environ.get("NOURI_HA_TOKEN", "").strip(),
HOME_ASSISTANT_USER_ID=os.environ.get("NOURI_HA_USER_ID", "").strip(),
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"),
+6 -2
View File
@@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def is_home_assistant_api_endpoint(endpoint: str) -> bool:
return endpoint.startswith("main.home_assistant_")
def normalize_login_value(raw: str) -> str: def normalize_login_value(raw: str) -> str:
return raw.strip().lower() return raw.strip().lower()
@@ -136,10 +140,10 @@ def load_logged_in_user():
g.user = None g.user = None
endpoint = request.endpoint or "" endpoint = request.endpoint or ""
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}: if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"} and not is_home_assistant_api_endpoint(endpoint):
return redirect(url_for("auth.setup")) return redirect(url_for("auth.setup"))
if request.method == "POST" and endpoint != "static": if request.method == "POST" and endpoint != "static" and not is_home_assistant_api_endpoint(endpoint):
token = session.get("_csrf_token") token = session.get("_csrf_token")
form_token = request.form.get("csrf_token") form_token = request.form.get("csrf_token")
if not token or token != form_token: if not token or token != form_token:
+5 -1
View File
@@ -77,6 +77,7 @@ def export_backup_archive(
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path: def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-")) temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
temp_root = temp_dir.resolve()
for member in archive.infolist(): for member in archive.infolist():
if not member.filename.startswith("uploads/") or member.is_dir(): if not member.filename.startswith("uploads/") or member.is_dir():
continue continue
@@ -84,8 +85,11 @@ def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
if not relative_target: if not relative_target:
continue continue
target_path = temp_dir / relative_target target_path = temp_dir / relative_target
resolved_target = target_path.resolve()
if not resolved_target.is_relative_to(temp_root):
raise ValueError("Das Backup enthält einen ungültigen Upload-Pfad.")
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member, "r") as source, target_path.open("wb") as destination: with archive.open(member, "r") as source, resolved_target.open("wb") as destination:
shutil.copyfileobj(source, destination) shutil.copyfileobj(source, destination)
return temp_dir return temp_dir
+1 -1
View File
@@ -15,7 +15,7 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS, DEFAULT_CATEGORY_BUILDERS,
) )
CURRENT_SCHEMA_VERSION = "1.3.3" CURRENT_SCHEMA_VERSION = "1.3.4"
ANIMAL_HINTS = ( ANIMAL_HINTS = (
"huhn", "huhn",
+218
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import functools
import secrets
from io import BytesIO from io import BytesIO
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
@@ -1009,6 +1011,143 @@ def normalize_new_item_name(value: str | None) -> str:
return " ".join((value or "").strip().split())[:120] return " ".join((value or "").strip().split())[:120]
def normalize_home_assistant_create_kind(value: str | None) -> str | None:
normalized = (value or "").strip().lower()
if normalized in {"food", "lebensmittel"}:
return "food"
if normalized in {"shopping", "article", "artikel", "einkaufsartikel"}:
return "shopping"
return None
def home_assistant_api_user():
configured_user_id = current_app.config.get("HOME_ASSISTANT_USER_ID", "")
if str(configured_user_id).isdigit():
return get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
AND users.id = ?
LIMIT 1
""",
(int(configured_user_id),),
).fetchone()
row = get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
AND users.role = 'admin'
ORDER BY users.id
LIMIT 1
""",
).fetchone()
if row is not None:
return row
return get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
ORDER BY users.id
LIMIT 1
"""
).fetchone()
def home_assistant_api_required(view):
@functools.wraps(view)
def wrapped_view(*args, **kwargs):
configured_token = current_app.config.get("HOME_ASSISTANT_TOKEN", "")
if not configured_token:
return jsonify({
"ok": False,
"status": "not_configured",
"message": "Home Assistant ist in Nouri noch nicht konfiguriert.",
}), 503
auth_header = request.headers.get("Authorization", "")
bearer_token = auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else ""
request_token = bearer_token or request.headers.get("X-Nouri-Token", "").strip()
if not request_token or not secrets.compare_digest(request_token, configured_token):
return jsonify({
"ok": False,
"status": "unauthorized",
"message": "Der Home-Assistant-Token passt nicht.",
}), 401
api_user = home_assistant_api_user()
if api_user is None:
return jsonify({
"ok": False,
"status": "no_user",
"message": "In Nouri gibt es noch keinen aktiven Nutzer fuer Home Assistant.",
}), 503
g.user = api_user
return view(*args, **kwargs)
return wrapped_view
def home_assistant_payload() -> dict:
payload = request.get_json(silent=True)
return payload if isinstance(payload, dict) else {}
def home_assistant_truthy(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"}
def item_api_payload(item: dict) -> dict:
return {
"id": item["id"],
"name": item["name"],
"kind": item["kind"],
"visibility": item["visibility"],
}
def shopping_api_message(status: str, item_name: str, note: str = "") -> str:
note_suffix = f" mit Hinweis {note}" if note else ""
if status == "added":
return f"{item_name} wurde{note_suffix} auf die Einkaufsliste gesetzt."
if status == "duplicate":
return f"{item_name} steht{note_suffix} schon auf der Einkaufsliste."
return f"{item_name} wurde verarbeitet."
def add_item_to_shopping_api(item: dict, note: str) -> dict:
result = ensure_item_or_missing_components_are_shopped(
item["id"],
g.user["id"],
item["visibility"],
shopping_note=note,
)
status = "added" if result["count"] else "duplicate"
return {
"ok": True,
"status": status,
"item": item_api_payload(item),
"note": note,
"result": result,
"message": shopping_api_message(status, item["name"], note),
}
def schedule_shopping_need( def schedule_shopping_need(
*, *,
item_id: int, item_id: int,
@@ -4588,6 +4727,85 @@ def item_remove_from_shopping(item_id: int):
return redirect(request.referrer or url_for("main.shopping_list")) return redirect(request.referrer or url_for("main.shopping_list"))
@main_bp.post("/api/home-assistant/shopping")
@home_assistant_api_required
def home_assistant_shopping():
payload = home_assistant_payload()
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
confirm_create = home_assistant_truthy(payload.get("confirm_create"))
if not item_name:
return jsonify({
"ok": False,
"status": "missing_name",
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
}), 400
item = find_shopping_item_by_name(item_name)
if item is not None:
return jsonify(add_item_to_shopping_api(item, note))
if confirm_create:
if create_as is None:
return jsonify({
"ok": False,
"status": "missing_create_kind",
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
"name": item_name,
"note": note,
"options": ["food", "shopping"],
}), 400
existing_before_create = find_shopping_item_by_name(item_name)
item = create_shopping_search_item(item_name, create_as)
response = add_item_to_shopping_api(item, note)
response["created"] = existing_before_create is None
return jsonify(response)
suggested_kind = create_as or "shopping"
return jsonify({
"ok": True,
"status": "needs_confirmation",
"name": item_name,
"note": note,
"suggested_create_as": suggested_kind,
"options": ["food", "shopping"],
"message": f"{item_name} kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?",
})
@main_bp.post("/api/home-assistant/shopping/confirm")
@home_assistant_api_required
def home_assistant_shopping_confirm():
payload = home_assistant_payload()
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
if not item_name:
return jsonify({
"ok": False,
"status": "missing_name",
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
}), 400
if create_as is None:
return jsonify({
"ok": False,
"status": "missing_create_kind",
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
"name": item_name,
"note": note,
"options": ["food", "shopping"],
}), 400
existing_before_create = find_shopping_item_by_name(item_name)
item = create_shopping_search_item(item_name, create_as)
response = add_item_to_shopping_api(item, note)
response["created"] = existing_before_create is None
return jsonify(response)
@main_bp.route("/shopping", methods=("GET", "POST")) @main_bp.route("/shopping", methods=("GET", "POST"))
@login_required @login_required
def shopping_list(): def shopping_list():
+19 -7
View File
@@ -1236,7 +1236,8 @@ h3 {
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%); border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
} }
.item-card-food { .item-card-food,
.item-card-meal {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.9rem; gap: 0.9rem;
align-content: start; align-content: start;
@@ -1248,20 +1249,23 @@ h3 {
opacity: 0.72; opacity: 0.72;
} }
.item-card-food .item-media-food { .item-card-food .item-media-food,
.item-card-meal .item-media-meal {
width: min(100%, 170px); width: min(100%, 170px);
justify-self: center; justify-self: center;
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 24px; border-radius: 24px;
} }
.item-card-food .item-body-food { .item-card-food .item-body-food,
.item-card-meal .item-body-meal {
justify-items: center; justify-items: center;
text-align: center; text-align: center;
gap: 0.25rem; gap: 0.25rem;
} }
.item-card-food .item-body-food h2 { .item-card-food .item-body-food h2,
.item-card-meal .item-body-meal h2 {
margin: 0; margin: 0;
font-size: 1.9rem; font-size: 1.9rem;
line-height: 1.08; line-height: 1.08;
@@ -1330,6 +1334,10 @@ h3 {
box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12); box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12);
} }
.item-card-icon-link {
text-decoration: none;
}
.item-card-icon-button:hover { .item-card-icon-button:hover {
background: color-mix(in srgb, var(--accent) 78%, #fff 22%); background: color-mix(in srgb, var(--accent) 78%, #fff 22%);
color: #201a17; color: #201a17;
@@ -1397,7 +1405,9 @@ h3 {
} }
.item-card-food:hover .item-card-hover-meta, .item-card-food:hover .item-card-hover-meta,
.item-card-food:focus-within .item-card-hover-meta { .item-card-food:focus-within .item-card-hover-meta,
.item-card-meal:hover .item-card-hover-meta,
.item-card-meal:focus-within .item-card-hover-meta {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateY(0); transform: translateY(0);
@@ -3613,11 +3623,13 @@ legend {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.item-card-food { .item-card-food,
.item-card-meal {
min-height: 0; min-height: 0;
} }
.item-card-food .item-media-food { .item-card-food .item-media-food,
.item-card-meal .item-media-meal {
width: min(100%, 156px); width: min(100%, 156px);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "nouri-v1-3-3"; const CACHE_NAME = "nouri-v1-3-4";
const OFFLINE_URL = "/static/pwa/offline.html"; const OFFLINE_URL = "/static/pwa/offline.html";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/css/styles.css", "/static/css/styles.css",
+44 -19
View File
@@ -162,8 +162,44 @@
</div> </div>
</article> </article>
{% else %} {% else %}
<article class="item-card"> <article class="item-card item-card-meal">
<div class="item-media"> {% if item.can_edit %}
<a class="item-card-cover-link" href="{{ url_for('main.item_edit', item_id=item.id) }}">
<span class="sr-only">{{ item.name }} bearbeiten</span>
</a>
<form
class="item-card-archive-form"
method="post"
action="{{ url_for('main.item_archive', item_id=item.id) }}"
onsubmit="return confirm('Willst du diese Mahlzeitenidee wirklich archivieren?');"
>
{{ csrf_input() }}
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
</form>
{% endif %}
<div class="item-card-quick-actions">
<a
class="item-card-icon-button item-card-icon-link"
href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}"
aria-label="{{ item.name }} im Tagesplan öffnen"
title="Im Tagesplan öffnen"
>
<span class="ui-icon icon-calendar"></span>
</a>
<form method="post" action="{{ url_for('main.item_remove_from_shopping' if item.is_on_shopping_list else 'main.item_add_to_shopping', item_id=item.id) }}">
{{ csrf_input() }}
<button
class="item-card-icon-button {% if item.is_on_shopping_list %}is-active{% else %}is-inactive{% endif %}"
type="submit"
aria-label="{% if item.is_on_shopping_list %}{{ item.name }} von der Einkaufsliste entfernen{% else %}{{ item.name }} auf die Einkaufsliste setzen{% endif %}"
title="{% if item.is_on_shopping_list %}Auf Einkaufsliste{% else %}Nicht auf Einkaufsliste{% endif %}"
>
<span class="ui-icon icon-cart-shopping"></span>
</button>
</form>
</div>
<div class="item-media item-media-meal">
{% if item.photo_filename %} {% if item.photo_filename %}
<img <img
src="{{ image_url(item.photo_filename, 'md') }}" src="{{ image_url(item.photo_filename, 'md') }}"
@@ -175,7 +211,12 @@
<div class="placeholder-tile">{{ item.name[:1] }}</div> <div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="item-body">
<div class="item-body item-body-meal">
<h2>{{ item.name }}</h2>
</div>
<div class="item-card-hover-meta" aria-hidden="true">
<div class="chip-row"> <div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span> <span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span> <span class="chip status-soft">{{ item.owner_label }}</span>
@@ -202,22 +243,6 @@
<p>{{ item.note }}</p> <p>{{ item.note }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="item-actions">
{% if item.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
{% endif %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
<form class="primary-action" method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
{{ csrf_input() }}
<button type="submit">Auf Einkaufsliste</button>
</form>
{% if item.can_edit %}
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Archivieren</button>
</form>
{% endif %}
</div>
</article> </article>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
+1 -1
View File
@@ -1,5 +1,5 @@
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" Pillow==12.2.0; python_version < "3.14"
fpdf2==2.8.3 fpdf2==2.8.3