diff --git a/CloudronManifest.json b/CloudronManifest.json index 070d68e..92c439a 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "1.3.0", - "upstreamVersion": "1.3.0", + "version": "1.3.2", + "upstreamVersion": "1.3.2", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_1.3.1.md b/RELEASE_NOTES_1.3.1.md new file mode 100644 index 0000000..b8e1153 --- /dev/null +++ b/RELEASE_NOTES_1.3.1.md @@ -0,0 +1,38 @@ +# Nouri 1.3.1 + +Nouri 1.3.1 ist ein kleiner Stabilitäts- und Einkaufslisten-Release auf Basis von 1.3.0. Der Fokus liegt auf zuverlässigeren Backups, besseren Einkaufshinweisen und einem ruhigeren Dark Theme. + +## Neu in 1.3.1 + +- Einkaufseinträge können jetzt einen kleinen Hinweis bekommen, zum Beispiel `TK`, `Dose` oder `frisch`. +- Derselbe Artikel kann mehrfach auf der Einkaufsliste stehen, wenn sich der Hinweis unterscheidet: + - `Erbsen · TK` + - `Erbsen · Dose` +- Hinweise werden klein auf den Einkaufskarten angezeigt. +- Einkaufshinweise lassen sich im Detaildialog eines Einkaufseintrags nachträglich bearbeiten. +- Die obere Einkaufssuche nutzt jetzt echte auswählbare Trefferkarten statt einer Browser-`datalist`, damit die Auswahl auch auf iOS zuverlässig funktioniert. +- Die Treffer werden erst während der Suche eingeblendet und liegen nicht mehr dauerhaft als lange Kartenliste unter dem Formular. +- Beim Anlegen und Bearbeiten von Mahlzeiten lassen sich Lebensmittel jetzt über eine schnelle Kachelsuche zusammenklicken. +- Ausgewählte Lebensmittel erscheinen sofort direkt unter dem Suchfeld. +- Nicht vorrätige Lebensmittel sind in der Mahlzeiten-Auswahl mit einem Einkaufswagen-Icon markiert. + +## Stabilität und Darstellung + +- Backup-Downloads werden jetzt erst nach dem vollständigen Response-Streaming aufgeräumt. +- Dadurch sollten heruntergeladene Backup-Zips nicht mehr mit inkonsistenten Zip-Größen abbrechen. +- Karten auf der Einkaufsliste und ähnliche schnelle Auswahlkarten haben im Dark Theme stabilere Kontraste. +- Die Mahlzeiten-Auswahl zeigt Zutaten jetzt mit Bild oder Baustein-Icon statt als reine Checkbox-Zeilen. + +## Technisch + +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.1`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.1` angehoben. +- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.1`. +- Die Datenbank ergänzt `shopping_note` für Einkaufseinträge. +- Die offene Einkaufsliste ist jetzt pro Lebensmittel und Hinweis eindeutig, nicht mehr nur pro Lebensmittel. + +## Hinweis zum Update + +- Bestehende Einkaufslisteneinträge bleiben erhalten und bekommen automatisch einen leeren Hinweis. +- Bestehende SQLite-Daten werden beim Start um das neue Feld und den angepassten Index ergänzt. +- Wie immer empfiehlt sich vor produktiven Cloudron-Updates ein reguläres Backup. diff --git a/RELEASE_NOTES_1.3.2.md b/RELEASE_NOTES_1.3.2.md new file mode 100644 index 0000000..7de39ca --- /dev/null +++ b/RELEASE_NOTES_1.3.2.md @@ -0,0 +1,23 @@ +# Nouri 1.3.2 + +Nouri 1.3.2 ist ein kleiner Bedienungs-Release für mobile Einkaufssuche und schnelleres Zusammenklicken von Mahlzeiten. + +## Neu in 1.3.2 + +- Die Einkaufssuche nutzt jetzt echte auswählbare Trefferkarten statt einer Browser-`datalist`. +- Damit lassen sich Lebensmittel auf iOS direkt nach der Suche antippen und auf die Einkaufsliste setzen. +- Beim Anlegen und Bearbeiten von Mahlzeiten werden Lebensmittel als Kacheln angezeigt. +- Ausgewählte Bestandteile erscheinen sofort direkt unter dem Suchfeld. +- Nicht vorrätige Lebensmittel werden ebenfalls angezeigt und mit einem Einkaufswagen-Icon markiert. +- Ein Einkaufswagen-Icon wurde aus `heinz.marketing` in die Nouri-Icons übernommen. + +## Technisch + +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.2`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.2` angehoben. +- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.2`. + +## Hinweis zum Update + +- Es ist keine manuelle Datenmigration nötig. +- Bestehende Daten bleiben erhalten. diff --git a/nouri/__init__.py b/nouri/__init__.py index 69143f6..09ca8f6 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str: ).strip() if manifest_version: return manifest_version - return "1.3.0" + return "1.3.2" def load_release_url() -> str: diff --git a/nouri/db.py b/nouri/db.py index cf11809..339e540 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -15,7 +15,7 @@ from .constants import ( DEFAULT_CATEGORY_BUILDERS, ) -CURRENT_SCHEMA_VERSION = "1.3.0" +CURRENT_SCHEMA_VERSION = "1.3.2" ANIMAL_HINTS = ( "huhn", diff --git a/nouri/main.py b/nouri/main.py index ea2f736..af8f295 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -3019,8 +3019,7 @@ def apply_item_set_to_shopping(set_id: int) -> dict: def render_item_form(kind: str, *, item: dict | None, form_data: dict): - food_search = form_data.get("food_search") or None - foods = fetch_food_options(query=food_search if kind == "meal" else None) + foods = fetch_food_options() if kind == "meal" else [] return render_template( "items/form.html", kind=kind, diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index bcd4217..ded3ef4 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -659,6 +659,10 @@ h3 { margin: 0 0 1rem; } +.selected-component-stack.is-live { + margin: 0.8rem 0 1rem; +} + .selected-components-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); @@ -677,6 +681,10 @@ h3 { align-content: start; } +.selected-component-card.is-needed { + border-color: color-mix(in srgb, var(--accent) 42%, var(--line) 58%); +} + .selected-component-main { display: grid; gap: 0.15rem; @@ -692,6 +700,103 @@ h3 { line-height: 1.25; } +.selected-component-main small { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.25; +} + +.meal-component-search { + display: grid; + gap: 0.75rem; +} + +.meal-component-results { + display: grid; + gap: 0.75rem; +} + +.meal-component-option-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.75rem; +} + +.meal-component-option { + position: relative; + display: block; +} + +.meal-component-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.meal-component-option-card { + min-height: 92px; + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + gap: 0.85rem; + align-items: center; + padding: 0.85rem 0.95rem; + border-radius: 22px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, var(--accent-soft) 14%); + color: var(--text); + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.meal-component-option-card:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--accent) 38%, var(--line) 62%); +} + +.meal-component-option input:focus-visible + .meal-component-option-card { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%); + outline-offset: 3px; +} + +.meal-component-option input:checked + .meal-component-option-card { + border-color: color-mix(in srgb, var(--accent) 58%, var(--line) 42%); + background: color-mix(in srgb, var(--accent-soft) 28%, var(--surface-strong) 72%); + box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 13%, transparent 87%); +} + +.meal-component-option-visual { + width: 58px; + height: 58px; + border-radius: 18px; + overflow: hidden; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--surface-soft) 84%, transparent 16%); + border: 1px solid color-mix(in srgb, var(--line) 80%, transparent 20%); +} + +.meal-component-option-visual img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.meal-component-option-visual .ui-icon { + width: 1.2rem; + height: 1.2rem; +} + +.meal-component-option-copy { + min-width: 0; + display: grid; + gap: 0.22rem; +} + +.meal-component-option-copy strong { + overflow-wrap: anywhere; + line-height: 1.18; +} + .selected-component-visual { display: flex; align-items: center; @@ -765,6 +870,42 @@ h3 { border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%); } +[data-theme="dark"] .selected-component-card.is-needed, +[data-theme="dark"] .meal-component-option input:checked + .meal-component-option-card { + border-color: color-mix(in srgb, rgba(243, 177, 125, 0.54) 52%, var(--line) 48%); +} + +[data-theme="dark"] .meal-component-option-card { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%), + color-mix(in srgb, var(--surface) 92%, #241f1d 8%) + ); + border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%); + color: var(--text); +} + +[data-theme="dark"] .meal-component-option input:checked + .meal-component-option-card { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 58%, #5d4a40 42%), + color-mix(in srgb, var(--surface) 88%, #302824 12%) + ); +} + +[data-theme="dark"] .meal-component-option-visual { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 60%, #453a37 40%), + color-mix(in srgb, var(--surface) 90%, #2b2523 10%) + ); + border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%); +} + +[data-theme="dark"] .food-status-badge.is-needed { + color: color-mix(in srgb, var(--accent-strong) 82%, white 18%); +} + [data-theme="dark"] .selected-component-fallback { background: linear-gradient( @@ -1226,6 +1367,10 @@ h3 { align-items: end; } +.shopping-add-form .shopping-add-results { + grid-column: 1 / -1; +} + .shopping-add-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); @@ -1256,6 +1401,12 @@ h3 { border-color: color-mix(in srgb, var(--line) 72%, var(--accent) 28%); } +.shopping-add-card[hidden], +.meal-component-option[hidden], +.selected-component-card[data-selected-preview-card][hidden] { + display: none; +} + .shopping-add-card-visual, .shopping-add-card-fallback, .shopping-entry-visual, @@ -1301,6 +1452,23 @@ h3 { font-size: 0.92rem; } +.food-status-badge { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 0.28rem; + color: var(--muted); +} + +.food-status-badge .ui-icon { + width: 0.86rem; + height: 0.86rem; +} + +.food-status-badge.is-needed { + color: color-mix(in srgb, var(--accent-strong) 72%, var(--text) 28%); +} + .shopping-entry-card { position: relative; display: grid; @@ -2904,6 +3072,11 @@ legend { mask-image: url("../icons/fa/cart-shopping.svg"); } +.icon-shopping-cart { + -webkit-mask-image: url("../icons/fa/shopping-cart.svg"); + mask-image: url("../icons/fa/shopping-cart.svg"); +} + .icon-calendar { -webkit-mask-image: url("../icons/fa/calendar.svg"); mask-image: url("../icons/fa/calendar.svg"); diff --git a/nouri/static/icons/fa/shopping-cart.svg b/nouri/static/icons/fa/shopping-cart.svg new file mode 100644 index 0000000..1f0ea1c --- /dev/null +++ b/nouri/static/icons/fa/shopping-cart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index c7b58a6..5bb3c9f 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -112,6 +112,7 @@ 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 hideWhenEmpty = input.hasAttribute("data-filter-hide-empty"); const scoreItem = (label, term) => { if (label === term) return 0; @@ -137,11 +138,13 @@ const term = input.value.trim().toLowerCase(); if (!term) { items.forEach((item, index) => { - item.hidden = hasLimit ? index >= resultLimit : false; + item.hidden = hideWhenEmpty || (hasLimit ? index >= resultLimit : false); }); + container.hidden = hideWhenEmpty; syncGroups(); return; } + container.hidden = false; const rankedMatches = items .map((item, index) => { @@ -167,6 +170,55 @@ }); }; + const initSelectedPreviews = () => { + document.querySelectorAll("[data-selected-preview]").forEach((preview) => { + const form = preview.closest("form"); + const sourceSelector = preview.getAttribute("data-selected-preview"); + if (!form || !sourceSelector) return; + + const source = document.querySelector(sourceSelector); + if (!source) return; + + const emptyText = preview.querySelector("[data-selected-preview-empty]"); + const cards = Array.from(preview.querySelectorAll("[data-selected-preview-card]")); + + const sync = () => { + let visibleCount = 0; + cards.forEach((card) => { + const value = card.getAttribute("data-selected-preview-card"); + const input = value + ? Array.from(form.querySelectorAll('input[name="component_ids"]')).find((candidate) => candidate.value === value) + : null; + const checked = input instanceof HTMLInputElement && input.checked; + card.hidden = !checked; + if (checked) visibleCount += 1; + }); + if (emptyText) { + emptyText.hidden = visibleCount > 0; + } + }; + + source.addEventListener("change", (event) => { + const target = event.target; + if (target instanceof HTMLInputElement && target.name === "component_ids") { + sync(); + } + }); + preview.addEventListener("click", (event) => { + const button = event.target.closest("[data-uncheck-component]"); + if (!(button instanceof HTMLElement)) return; + const value = button.getAttribute("data-uncheck-component"); + if (!value) return; + const input = Array.from(form.querySelectorAll('input[name="component_ids"]')).find((candidate) => candidate.value === value); + if (input instanceof HTMLInputElement) { + input.checked = false; + input.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + sync(); + }); + }; + const initIosPullToRefresh = () => { const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); @@ -270,6 +322,7 @@ initPostFormScrollMemory(); initMobileSheet(); initFilterInputs(); + initSelectedPreviews(); initIosPullToRefresh(); initDialogs(); }); diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js index 7e1ba66..9974711 100644 --- a/nouri/static/pwa/service-worker.js +++ b/nouri/static/pwa/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "nouri-v1-0-0"; +const CACHE_NAME = "nouri-v1-3-2"; const OFFLINE_URL = "/static/pwa/offline.html"; const STATIC_ASSETS = [ "/static/css/styles.css", diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index 94d21bd..c4e99ba 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -205,11 +205,26 @@
Bestandteile der Mahlzeitenidee

Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.

- {% if selected_components %} -
-

Schon ausgewählt

-
- {% for component in selected_components %} + +
+

Ausgewählt

+

Noch nichts ausgewählt.

+
+ {% for group in food_groups %} + {% for component in group["items"] %} {% set component_icon_class = { 'protein': 'icon-component-protein', 'carb': 'icon-component-carb', @@ -220,9 +235,11 @@ 'seeds': 'icon-component-seeds', 'neutral': 'icon-component-neutral', }.get(component.primary_builder_key or component.base_type, 'icon-component-neutral') %} -
- - @@ -242,41 +259,60 @@
{{ component.name }} + + + {{ component.availability_label }} +
{% endfor %} -
+ {% endfor %}
- {% endif %} -
- -
-

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

+

Während der Suche zeigt Nouri die passendsten Lebensmittel. Nicht vorrätige Lebensmittel sind mit Einkaufswagen markiert.

{% if food_groups %} -
+
{% for group in food_groups %}

{{ group["title"] }}

{{ group["items"]|length }} Einträge
-
+
{% for food in group["items"] %} -
diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index 910eddf..ec4235a 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -17,9 +17,12 @@ - +
{% for item in addable_items %} - + {% set item_icon_class = { + 'protein': 'icon-component-protein', + 'carb': 'icon-component-carb', + 'veg': 'icon-component-veg', + 'fruit': 'icon-component-fruit', + 'dairy': 'icon-component-dairy', + 'nuts': 'icon-component-nuts', + 'seeds': 'icon-component-seeds', + 'neutral': 'icon-component-neutral', + }.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %} + {% endfor %} - +