2 Commits

Author SHA1 Message Date
hnzio aff40eff49 Release Nouri 1.3.3 with shopping articles 2026-05-01 14:32:30 +02:00
hnzio 6b2c495cf2 Add food card quick status actions 2026-04-29 12:43:23 +02:00
12 changed files with 490 additions and 40 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.2",
"upstreamVersion": "1.3.2",
"version": "1.3.3",
"upstreamVersion": "1.3.3",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
+38
View File
@@ -0,0 +1,38 @@
# Nouri 1.3.3
Nouri 1.3.3 erweitert die Einkaufsliste um freie Einkaufsartikel, ohne die bestehende Lebensmittel- und Rezeptlogik umzubauen. Der Fokus liegt darauf, Alltagsdinge wie Drogerie, Haushalt oder Garten genauso schnell auf die Liste setzen zu können wie Lebensmittel.
## Neu in 1.3.3
- Die Einkaufssuche kann jetzt Lebensmittel und allgemeine Artikel finden.
- Neue Artikel können direkt aus dem Suchbegriff angelegt werden:
- `Als Lebensmittel anlegen`
- `Als Einkaufsartikel anlegen`
- Einkaufsartikel wie `Blumenerde`, `Deo`, `Insektenschutz` oder `Sonnencreme` werden intern gespeichert.
- Reine Einkaufsartikel bleiben aus Mahlzeiten, Rezeptvorschlägen und Lebensmittel-Details heraus.
- Nicht vorhandene Lebensmittel können aus der Einkaufsliste heraus schnell als unsortiertes Lebensmittel angelegt werden.
## Einkaufsliste
- Bereits angelegte Einkaufsartikel erscheinen bei späteren Suchen wieder als Treffer.
- Einkaufshinweise wie `TK`, `Dose`, `frisch` oder andere kurze Notizen funktionieren weiterhin.
- Derselbe Artikel kann mit unterschiedlichen Einkaufshinweisen mehrfach auf der Liste stehen.
- Einkaufsartikel werden auf der Liste als `Einkaufsartikel` markiert und nutzen ein Einkaufswagen-Symbol.
- Beim Abhaken eines Einkaufsartikels wird er als eingekauft markiert, ohne ihn als zuhause vorhandenes Lebensmittel zu behandeln.
## Daten und Migration
- Das Items-Schema unterstützt jetzt zusätzlich den internen Typ `shopping`.
- Bestehende Datenbanken werden beim Start migriert, damit der neue Typ auch bei Updates funktioniert.
- Der Index für Items nach Typ und Name wird bei Schema-Upgrades sauber wieder angelegt.
## Betrieb
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.3`.
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.3` angehoben.
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.3`.
## Upgrade-Hinweis
- Bestehende Lebensmittel, Mahlzeitenideen und Einkaufseinträge bleiben erhalten.
- Nach dem Update können freie Einkaufsartikel direkt unter `Einkauf` über das Suchfeld angelegt werden.
+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.2"
return "1.3.3"
def load_release_url() -> str:
+111 -1
View File
@@ -15,7 +15,7 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS,
)
CURRENT_SCHEMA_VERSION = "1.3.2"
CURRENT_SCHEMA_VERSION = "1.3.3"
ANIMAL_HINTS = (
"huhn",
@@ -423,6 +423,109 @@ def set_meta(database: sqlite3.Connection, key: str, value: str) -> None:
)
def item_kind_constraint_supports_shopping(database: sqlite3.Connection) -> bool:
row = database.execute(
"""
SELECT sql
FROM sqlite_master
WHERE type = 'table' AND name = 'items'
"""
).fetchone()
return bool(row and row["sql"] and "'shopping'" in row["sql"])
def migrate_items_kind_constraint(database: sqlite3.Connection) -> None:
if not table_exists(database, "items") or item_kind_constraint_supports_shopping(database):
return
columns = table_columns(database, "items")
if "kind" not in columns:
return
foreign_keys_enabled = bool(database.execute("PRAGMA foreign_keys").fetchone()[0])
if foreign_keys_enabled:
database.execute("PRAGMA foreign_keys = OFF")
try:
database.execute("DROP TABLE IF EXISTS items_new")
database.execute(
"""
CREATE TABLE items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
name TEXT NOT NULL,
category TEXT,
base_type TEXT NOT NULL DEFAULT 'neutral',
flavor_profile TEXT NOT NULL DEFAULT 'neutral',
suggestion_role TEXT NOT NULL DEFAULT 'base',
suggestion_priority TEXT NOT NULL DEFAULT 'normal',
can_be_meal_core INTEGER NOT NULL DEFAULT 0,
meal_type TEXT,
meal_tags TEXT NOT NULL DEFAULT '',
energy_density TEXT NOT NULL DEFAULT 'neutral',
note TEXT,
photo_filename TEXT,
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
is_archived INTEGER NOT NULL DEFAULT 0,
is_quick_added INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
updated_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
target_columns = [
"id",
"household_id",
"owner_user_id",
"target_user_id",
"visibility",
"kind",
"name",
"category",
"base_type",
"flavor_profile",
"suggestion_role",
"suggestion_priority",
"can_be_meal_core",
"meal_type",
"meal_tags",
"energy_density",
"note",
"photo_filename",
"availability_state",
"is_archived",
"is_quick_added",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
copy_columns = [column for column in target_columns if column in columns]
quoted_columns = ", ".join(copy_columns)
database.execute(
f"""
INSERT INTO items_new ({quoted_columns})
SELECT {quoted_columns}
FROM items
"""
)
database.execute("DROP TABLE items")
database.execute("ALTER TABLE items_new RENAME TO items")
finally:
if foreign_keys_enabled:
database.execute("PRAGMA foreign_keys = ON")
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
ensure_meta_table(database)
database.execute(
@@ -739,6 +842,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0")
migrate_items_kind_constraint(database)
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''")
@@ -821,6 +925,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
WHERE email IS NOT NULL AND email != ''
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_kind_name
ON items (kind, name)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
+88 -9
View File
@@ -57,7 +57,7 @@ from .constants import (
WEEKDAY_OPTIONS,
WEEK_TEMPLATE_NAME_SUGGESTIONS,
)
from .db import get_db
from .db import get_db, infer_food_flavor_profile, infer_food_profile
from .images import (
allowed_image_file,
save_photo_with_variants,
@@ -1005,6 +1005,10 @@ def normalize_shopping_note(value: str | None) -> str:
return " ".join((value or "").strip().split())[:80]
def normalize_new_item_name(value: str | None) -> str:
return " ".join((value or "").strip().split())[:120]
def schedule_shopping_need(
*,
item_id: int,
@@ -1298,8 +1302,8 @@ def fetch_items_by_ids(item_ids: list[int]) -> list[dict]:
return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id]
def find_shopping_food_by_name(name: str) -> dict | None:
normalized_name = name.strip().lower()
def find_shopping_item_by_name(name: str) -> dict | None:
normalized_name = normalize_new_item_name(name).lower()
if not normalized_name:
return None
row = get_db().execute(
@@ -1317,11 +1321,11 @@ def find_shopping_food_by_name(name: str) -> dict | None:
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE items.kind = 'food'
WHERE items.kind IN ('food', 'shopping')
AND items.is_archived = 0
AND LOWER(items.name) = ?
AND {visible_clause('items')}
ORDER BY LOWER(items.name), items.id
ORDER BY CASE items.kind WHEN 'food' THEN 0 ELSE 1 END, LOWER(items.name), items.id
LIMIT 1
""",
[normalized_name, *visible_params()],
@@ -1331,6 +1335,64 @@ def find_shopping_food_by_name(name: str) -> dict | None:
return attach_builder_keys(attach_dayparts(describe_records([row])))[0]
def create_shopping_search_item(name: str, kind: str) -> dict:
normalized_name = normalize_new_item_name(name)
if not normalized_name:
raise ValueError("Bitte gib zuerst einen Namen ein.")
if kind not in {"food", "shopping"}:
raise ValueError("Bitte wähle aus, ob es ein Lebensmittel oder ein Einkaufsartikel ist.")
existing = find_shopping_item_by_name(normalized_name)
if existing is not None:
return existing
if kind == "food":
profile = infer_food_profile(normalized_name, "Unsortiert", "neutral")
category = "Unsortiert"
note = "Aus der Einkaufssuche angelegt. Details später ergänzen."
is_quick_added = 1
else:
profile = {
"base_type": "neutral",
"suggestion_role": "cooking",
"suggestion_priority": "never",
"can_be_meal_core": 0,
}
category = "Einkaufsartikel"
note = "Einkaufsartikel ohne Rezeptlogik."
is_quick_added = 0
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, visibility, kind, name, category,
base_type, flavor_profile, suggestion_role, suggestion_priority,
can_be_meal_core, energy_density, availability_state, note,
is_quick_added, created_by, updated_by
)
VALUES (?, ?, 'shared', ?, ?, ?, ?, ?, ?, ?, ?, 'neutral', 'idea', ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
kind,
normalized_name,
category,
profile["base_type"],
infer_food_flavor_profile(normalized_name, category, profile["base_type"], profile["suggestion_role"]),
profile["suggestion_role"],
profile["suggestion_priority"],
profile["can_be_meal_core"],
note,
is_quick_added,
g.user["id"],
g.user["id"],
),
)
get_db().commit()
return get_item(int(cursor.lastrowid))
def fetch_shopping_entries():
rows = get_db().execute(
f"""
@@ -4390,6 +4452,7 @@ def mark_shopping_entry_checked(entry_id: int) -> dict:
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
(g.user["id"], entry_id),
)
if item["kind"] != "shopping":
get_db().execute(
"""
UPDATE items
@@ -4495,6 +4558,9 @@ def item_mark_bought(item_id: int):
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.shopping_list"))
if item["kind"] == "shopping":
flash(f"{item['name']} wurde als eingekauft markiert.", "success")
else:
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
return redirect(request.referrer or url_for("main.shopping_list"))
@@ -4528,17 +4594,24 @@ def shopping_list():
if request.method == "POST":
selected_item_id = request.form.get("item_id", "").strip()
item_search = request.form.get("item_search", "").strip()
create_as = request.form.get("create_as", "").strip()
create_item_name = normalize_new_item_name(request.form.get("create_item_name") or item_search)
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
item = None
if selected_item_id.isdigit():
if create_as in {"food", "shopping"}:
try:
item = create_shopping_search_item(create_item_name, create_as)
except ValueError as exc:
flash(str(exc), "error")
elif selected_item_id.isdigit():
try:
item = get_item(int(selected_item_id))
except ValueError as exc:
flash(str(exc), "error")
elif item_search:
item = find_shopping_food_by_name(item_search)
item = find_shopping_item_by_name(item_search)
if item is None:
flash("Bitte ein Lebensmittel aus der Suche auswählen.", "error")
flash("Bitte einen Treffer auswählen oder den Begriff als Lebensmittel bzw. Einkaufsartikel anlegen.", "error")
else:
flash("Bitte zuerst etwas auswählen.", "error")
@@ -4560,7 +4633,10 @@ def shopping_list():
entries = fetch_shopping_entries()
upcoming_entries = fetch_upcoming_shopping_needs()
addable_items = fetch_items(kind="food", include_archived=False, include_quick_added=True)
addable_items = [
item for item in fetch_items(include_archived=False, include_quick_added=True)
if item["kind"] in {"food", "shopping"}
]
household_settings = get_household_settings()
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
return render_template(
@@ -4581,6 +4657,9 @@ def shopping_check(entry_id: int):
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
if item["kind"] == "shopping":
flash(f"{item['name']} wurde als eingekauft markiert.", "success")
else:
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
+1 -1
View File
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS items (
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
name TEXT NOT NULL,
category TEXT,
base_type TEXT NOT NULL DEFAULT 'neutral',
+155
View File
@@ -1082,6 +1082,16 @@ h3 {
color: #ece8e4;
}
.status-missing {
background: rgba(210, 125, 115, 0.18);
color: color-mix(in srgb, #9f4339 72%, var(--text) 28%);
}
[data-theme="dark"] .status-missing {
background: rgba(210, 125, 115, 0.22);
color: #f2d0ca;
}
.status-unsorted {
background: rgba(184, 161, 108, 0.18);
}
@@ -1095,6 +1105,10 @@ h3 {
background: var(--lilac-soft);
}
.status-shopping {
background: color-mix(in srgb, var(--mint-soft) 70%, var(--sky-soft) 30%);
}
.item-card {
position: relative;
display: grid;
@@ -1289,6 +1303,75 @@ h3 {
transform: translateY(-1px);
}
.item-card-quick-actions {
position: absolute;
top: 0.9rem;
left: 0.9rem;
z-index: 3;
display: flex;
gap: 0.45rem;
}
.item-card-quick-actions form {
margin: 0;
}
.item-card-icon-button {
width: 2.45rem;
height: 2.45rem;
min-width: 2.45rem;
padding: 0;
border-radius: 999px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--surface-strong) 84%, var(--accent-soft) 16%);
color: var(--text);
border: 1px solid color-mix(in srgb, var(--line) 62%, var(--accent) 38%);
box-shadow: 0 10px 22px rgba(70, 48, 34, 0.12);
}
.item-card-icon-button:hover {
background: color-mix(in srgb, var(--accent) 78%, #fff 22%);
color: #201a17;
transform: translateY(-1px) scale(1.02);
}
.item-card-icon-button.is-active,
.item-card-icon-button:disabled {
opacity: 1;
background: color-mix(in srgb, var(--accent) 82%, var(--surface-strong) 18%);
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 68%, var(--line) 32%);
transform: none;
}
.item-card-icon-button:disabled {
cursor: default;
}
.item-card-icon-button.is-inactive {
background: color-mix(in srgb, var(--surface-soft) 82%, #8a674f 18%);
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
border-color: color-mix(in srgb, var(--line) 72%, #8a674f 28%);
}
.item-card-icon-button.is-home {
background: color-mix(in srgb, var(--accent) 82%, var(--surface-strong) 18%);
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 68%, var(--line) 32%);
}
.item-card-icon-button.is-missing {
background: rgba(210, 125, 115, 0.18);
color: color-mix(in srgb, #9f4339 74%, var(--text) 26%);
border-color: color-mix(in srgb, rgba(210, 125, 115, 0.42) 62%, var(--line) 38%);
}
.item-card-icon-button .ui-icon {
width: 1.05rem;
height: 1.05rem;
}
.item-card-hover-meta {
position: absolute;
inset: 0;
@@ -1325,6 +1408,43 @@ h3 {
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
}
[data-theme="dark"] button.item-card-icon-button,
[data-theme="dark"] .item-card-icon-button {
background: color-mix(in srgb, var(--surface-soft) 70%, rgba(33, 28, 27, 0.5) 30%);
color: var(--text);
border-color: color-mix(in srgb, var(--line) 50%, rgba(243, 177, 125, 0.24) 50%);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.24);
}
[data-theme="dark"] button.item-card-icon-button:hover,
[data-theme="dark"] .item-card-icon-button:hover {
background: #d7935f;
color: #201a17;
}
[data-theme="dark"] .item-card-icon-button.is-active,
[data-theme="dark"] .item-card-icon-button:disabled,
[data-theme="dark"] button.item-card-icon-button.is-active,
[data-theme="dark"] button.item-card-icon-button:disabled {
background: #d7935f;
color: #201a17;
border-color: color-mix(in srgb, var(--accent-strong) 66%, rgba(243, 177, 125, 0.24) 34%);
}
[data-theme="dark"] .item-card-icon-button.is-inactive,
[data-theme="dark"] button.item-card-icon-button.is-inactive {
background: color-mix(in srgb, var(--surface-soft) 76%, #6c5141 24%);
color: color-mix(in srgb, var(--muted) 86%, white 14%);
border-color: color-mix(in srgb, var(--line) 62%, #8a674f 38%);
}
[data-theme="dark"] .item-card-icon-button.is-missing,
[data-theme="dark"] button.item-card-icon-button.is-missing {
background: rgba(210, 125, 115, 0.22);
color: #f2d0ca;
border-color: color-mix(in srgb, rgba(210, 125, 115, 0.44) 62%, var(--line) 38%);
}
[data-theme="dark"] .item-card-hover-meta {
background: linear-gradient(
180deg,
@@ -1371,6 +1491,31 @@ h3 {
grid-column: 1 / -1;
}
.shopping-create-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.65rem;
border-radius: 18px;
background: color-mix(in srgb, var(--surface-soft) 76%, transparent 24%);
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent 22%);
}
.shopping-create-actions[hidden] {
display: none;
}
.shopping-create-actions p {
flex: 1 1 16rem;
margin: 0;
}
.shopping-create-actions button {
flex: 0 0 auto;
}
.shopping-add-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
@@ -1626,6 +1771,11 @@ h3 {
width: 100%;
}
.shopping-create-actions {
display: grid;
grid-template-columns: 1fr;
}
.shopping-entry-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -3057,6 +3207,11 @@ legend {
mask-image: url("../icons/fa/house.svg");
}
.icon-house-xmark {
-webkit-mask-image: url("../icons/fa/house-circle-xmark.svg");
mask-image: url("../icons/fa/house-circle-xmark.svg");
}
.icon-utensils {
-webkit-mask-image: url("../icons/fa/utensils.svg");
mask-image: url("../icons/fa/utensils.svg");
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! 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="M272 70.1C281.1 61.9 294.9 61.9 304 70.1L533.2 275.6C521.2 273.2 508.7 272 496 272C484 272 472.2 273.1 460.9 275.2L288 120.2L128 263.7L128 512C128 520.8 135.2 528 144 528L192 528L192 424C192 384.2 224.2 352 264 352L312 352C320.8 352 329.2 353.6 336.9 356.4C327.8 369.9 320.3 384.5 314.8 400.1C313.9 400 312.9 399.9 311.9 399.9L263.9 399.9C250.6 399.9 239.9 410.6 239.9 423.9L239.9 527.9L314.8 527.9C320.9 545.2 329.4 561.3 339.9 575.9L143.9 575.9C108.6 575.9 79.9 547.2 79.9 511.9L79.9 306.6L71.9 313.8C62 322.6 46.9 321.8 38 312C29.1 302.2 30 287 39.8 278.1L272 70.1zM496 320C575.5 320 640 384.5 640 464C640 543.5 575.5 608 496 608C416.5 608 352 543.5 352 464C352 384.5 416.5 320 496 320zM555.3 427.3C561.5 421.1 561.5 410.9 555.3 404.7C549.1 398.5 538.9 398.5 532.7 404.7L496 441.4L459.3 404.7C453.1 398.5 442.9 398.5 436.7 404.7C430.5 410.9 430.5 421.1 436.7 427.3L473.4 464L436.7 500.7C430.5 506.9 430.5 517.1 436.7 523.3C442.9 529.5 453.1 529.5 459.3 523.3L496 486.6L532.7 523.3C538.9 529.5 549.1 529.5 555.3 523.3C561.5 517.1 561.5 506.9 555.3 500.7L518.6 464L555.3 427.3z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+20
View File
@@ -219,6 +219,25 @@
});
};
const initCreateFromSearch = () => {
document.querySelectorAll("[data-create-from]").forEach((container) => {
const inputSelector = container.getAttribute("data-create-from");
if (!inputSelector) return;
const input = document.querySelector(inputSelector);
const hiddenName = container.querySelector("[data-create-name]");
if (!(input instanceof HTMLInputElement) || !(hiddenName instanceof HTMLInputElement)) return;
const sync = () => {
const value = input.value.trim().replace(/\s+/g, " ");
hiddenName.value = value;
container.hidden = value.length === 0;
};
input.addEventListener("input", sync);
sync();
});
};
const initIosPullToRefresh = () => {
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
@@ -323,6 +342,7 @@
initMobileSheet();
initFilterInputs();
initSelectedPreviews();
initCreateFromSearch();
initIosPullToRefresh();
initDialogs();
});
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "nouri-v1-3-2";
const CACHE_NAME = "nouri-v1-3-3";
const OFFLINE_URL = "/static/pwa/offline.html";
const STATIC_ASSETS = [
"/static/css/styles.css",
+30
View File
@@ -86,6 +86,36 @@
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
</form>
{% endif %}
<div class="item-card-quick-actions">
{% if item.can_edit %}
{% if item.is_home %}
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=item.id) }}">
{{ csrf_input() }}
<button class="item-card-icon-button is-home" type="submit" aria-label="{{ item.name }} als nicht mehr da markieren" title="Lebensmittel ist zuhause">
<span class="ui-icon icon-house"></span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
{{ csrf_input() }}
<button class="item-card-icon-button is-missing" type="submit" aria-label="{{ item.name }} als zuhause markieren" title="Lebensmittel ist nicht zuhause">
<span class="ui-icon icon-house-xmark"></span>
</button>
</form>
{% endif %}
{% endif %}
<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-food">
{% if item.photo_filename %}
+24 -7
View File
@@ -13,11 +13,12 @@
<form method="post" class="shopping-add-form">
{{ csrf_input() }}
<label>
Lebensmittel suchen
Lebensmittel oder Artikel suchen
<input
type="text"
name="item_search"
placeholder="Nach Lebensmitteln suchen"
id="shopping-item-search"
placeholder="Nach Lebensmitteln oder Artikeln suchen"
autocomplete="off"
data-filter-input
data-filter-target="#shopping-food-options"
@@ -46,12 +47,15 @@
'seeds': 'icon-component-seeds',
'neutral': 'icon-component-neutral',
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
{% if item.kind == 'shopping' %}
{% set item_icon_class = 'icon-cart-shopping' %}
{% endif %}
<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 }}"
data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }} {{ item.base_type_label|lower }} {{ item.availability_label|lower }} {% if item.kind == 'shopping' %}einkaufsartikel artikel drogerie haushalt{% endif %}"
>
<span class="shopping-add-card-visual">
{% if item.photo_filename %}
@@ -69,11 +73,17 @@
</span>
<span class="shopping-add-card-copy">
<strong>{{ item.name }}</strong>
<small>{{ item.availability_label }}</small>
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
</span>
</button>
{% endfor %}
</div>
<div class="shopping-create-actions" data-create-from="#shopping-item-search" hidden>
<input type="hidden" name="create_item_name" data-create-name>
<p class="muted">Kein passender Treffer? Direkt aus dem Suchbegriff anlegen:</p>
<button class="ghost-button" type="submit" name="create_as" value="food">Als Lebensmittel anlegen</button>
<button class="ghost-button" type="submit" name="create_as" value="shopping">Als Einkaufsartikel anlegen</button>
</div>
<button type="submit">Auf die Liste</button>
</form>
</section>
@@ -97,6 +107,9 @@
'seeds': 'icon-component-seeds',
'neutral': 'icon-component-neutral',
}.get(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %}
{% if entry.item_kind == 'shopping' %}
{% set entry_icon_class = 'icon-cart-shopping' %}
{% endif %}
<article class="shopping-entry-card">
<div class="shopping-entry-row">
<div
@@ -165,6 +178,8 @@
{% elif entry.needed_for_label %}
Für {{ entry.needed_for_label }}
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
{% elif entry.item_kind == 'shopping' %}
Einkaufsartikel
{% elif entry.is_home %}
Zuhause vorhanden
{% else %}
@@ -175,7 +190,7 @@
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
</div>
<div class="shopping-entry-dialog-actions">
{% if entry.can_edit %}
{% if entry.can_edit and entry.item_kind != 'shopping' %}
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=entry.item_id) }}">Bearbeiten</a>
{% endif %}
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
@@ -201,22 +216,24 @@
{{ csrf_input() }}
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
</form>
{% if entry.is_home %}
{% if entry.item_kind != 'shopping' and entry.is_home %}
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Nicht mehr da</button>
</form>
{% else %}
{% elif entry.item_kind != 'shopping' %}
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Als zuhause markieren</button>
</form>
{% endif %}
{% if entry.item_kind != 'shopping' %}
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Archivieren</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</dialog>