1 Commits

Author SHA1 Message Date
hnzio 8fc2492918 Release Nouri 1.3.2 with mobile selection improvements 2026-04-29 10:54:40 +02:00
12 changed files with 402 additions and 41 deletions
+2 -2
View File
@@ -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,
+38
View File
@@ -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.
+23
View File
@@ -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.
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -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,
+173
View File
@@ -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");
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M0 0L72 0C125 0 168 43 168 96L168 120L576 120L516.9 359.3C506.2 402 467.8 432 423.8 432L168 432C115 432 72 389 72 336L72 96C72 69.5 50.5 48 24 48L0 48L0 0zM168 168L168 336C168 362.5 189.5 384 216 384L375.8 384C397.8 384 417 369 422.4 347.6L467.3 168L168 168zM240 528C240 554.5 225.6 576 192 576C158.4 576 144 554.5 144 528C144 501.5 158.4 480 192 480C225.6 480 240 501.5 240 528zM384 576C417.6 576 432 554.5 432 528C432 501.5 417.6 480 384 480C350.4 480 336 501.5 336 528C336 554.5 350.4 576 384 576z"/></svg>

After

Width:  |  Height:  |  Size: 768 B

+54 -1
View File
@@ -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();
});
+1 -1
View File
@@ -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",
+64 -28
View File
@@ -205,11 +205,26 @@
<fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend>
<p class="muted">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.</p>
{% if selected_components %}
<div class="selected-component-stack">
<p class="helper-text">Schon ausgewählt</p>
<div class="meal-component-search">
<label class="wide">
Lebensmittel suchen
<input
type="text"
name="food_search"
value="{{ form_data.food_search }}"
placeholder="z. B. Reis, Banane, Joghurt"
data-filter-input
data-filter-target="#meal-components-list"
data-filter-limit="8"
>
</label>
</div>
<div class="selected-component-stack is-live" data-selected-preview="#meal-components-list">
<p class="helper-text">Ausgewählt</p>
<p class="helper-text" data-selected-preview-empty>Noch nichts ausgewählt.</p>
<div class="selected-components-grid">
{% for component in selected_components %}
{% 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') %}
<article class="selected-component-card">
<input type="hidden" name="component_ids" value="{{ component.id }}">
<button class="selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
<article
class="selected-component-card {% if not component.is_home %}is-needed{% endif %}"
data-selected-preview-card="{{ component.id }}"
>
<button class="selected-component-remove" type="button" data-uncheck-component="{{ component.id }}">
<span aria-hidden="true">×</span>
<span class="sr-only">{{ component.name }} entfernen</span>
</button>
@@ -242,41 +259,60 @@
</div>
<div class="selected-component-main">
<strong>{{ component.name }}</strong>
<small class="food-status-badge {% if not component.is_home %}is-needed{% endif %}">
<span class="ui-icon {% if component.is_home %}icon-house{% else %}icon-shopping-cart{% endif %}"></span>
<span>{{ component.availability_label }}</span>
</small>
</div>
</article>
{% endfor %}
{% endfor %}
</div>
</div>
{% endif %}
<div class="inline-form">
<label class="wide">
Lebensmittel suchen
<input
type="text"
name="food_search"
value="{{ form_data.food_search }}"
placeholder="z. B. Reis, Banane, Joghurt"
data-filter-input
data-filter-target="#meal-components-list"
data-filter-limit="3"
>
</label>
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
</div>
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
<p class="helper-text">Während der Suche zeigt Nouri die passendsten Lebensmittel. Nicht vorrätige Lebensmittel sind mit Einkaufswagen markiert.</p>
{% if food_groups %}
<div class="stack-sections" id="meal-components-list">
<div class="meal-component-results" id="meal-components-list">
{% for group in food_groups %}
<div class="component-group">
<div class="panel-head">
<h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span>
</div>
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
<div class="meal-component-option-grid" data-filter-group>
{% for food in group["items"] %}
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }}">
{% set food_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(food.primary_builder_key or food.base_type, 'icon-component-neutral') %}
<label class="meal-component-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }} {{ food.availability_label|lower }}">
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
<span>{{ food.name }} · {{ food.base_type_label }} · {{ food.visibility_label }}</span>
<span class="meal-component-option-card">
<span class="meal-component-option-visual">
{% if food.photo_filename %}
<img
src="{{ image_url(food.photo_filename, 'md') }}"
srcset="{{ image_srcset(food.photo_filename) }}"
sizes="{{ image_sizes('grid') }}"
alt=""
loading="lazy">
{% else %}
<span class="ui-icon {{ food_icon_class }}"></span>
{% endif %}
</span>
<span class="meal-component-option-copy">
<strong>{{ food.name }}</strong>
<small class="food-status-badge {% if not food.is_home %}is-needed{% endif %}">
<span class="ui-icon {% if food.is_home %}icon-house{% else %}icon-shopping-cart{% endif %}"></span>
<span>{{ food.availability_label }}</span>
</small>
</span>
</span>
</label>
{% endfor %}
</div>
+42 -4
View File
@@ -17,9 +17,12 @@
<input
type="text"
name="item_search"
list="shopping-food-options"
placeholder="Nach Lebensmitteln suchen"
autocomplete="off"
data-filter-input
data-filter-target="#shopping-food-options"
data-filter-limit="8"
data-filter-hide-empty
>
</label>
<label>
@@ -31,11 +34,46 @@
placeholder="z. B. TK, Dose, frisch"
>
</label>
<datalist id="shopping-food-options">
<div class="shopping-add-grid shopping-add-results" id="shopping-food-options">
{% for item in addable_items %}
<option value="{{ item.name }}"></option>
{% 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') %}
<button
class="shopping-add-card"
type="submit"
name="item_id"
value="{{ item.id }}"
data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }} {{ item.base_type_label|lower }} {{ item.availability_label|lower }}"
>
<span class="shopping-add-card-visual">
{% if item.photo_filename %}
<img
src="{{ image_url(item.photo_filename, 'md') }}"
srcset="{{ image_srcset(item.photo_filename) }}"
sizes="{{ image_sizes('grid') }}"
alt=""
loading="lazy">
{% else %}
<span class="shopping-add-card-fallback">
<span class="ui-icon {{ item_icon_class }}"></span>
</span>
{% endif %}
</span>
<span class="shopping-add-card-copy">
<strong>{{ item.name }}</strong>
<small>{{ item.availability_label }}</small>
</span>
</button>
{% endfor %}
</datalist>
</div>
<button type="submit">Auf die Liste</button>
</form>
</section>