7 Commits

18 changed files with 1289 additions and 168 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.4",
"upstreamVersion": "1.3.4",
"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.
+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.
+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`.
+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.4"
def load_release_url() -> str:
+15 -3
View File
@@ -52,13 +52,25 @@ def export_backup_archive(
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))
uploads_snapshot_dir = Path(tempfile.mkdtemp(prefix="nouri-backup-uploads-"))
try:
if uploads_root.exists():
for file_path in uploads_root.rglob("*"):
if file_path.is_file():
if not file_path.is_file():
continue
relative_path = file_path.relative_to(uploads_root)
snapshot_path = uploads_snapshot_dir / relative_path
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, snapshot_path)
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
for file_path in uploads_snapshot_dir.rglob("*"):
if file_path.is_file():
relative_path = file_path.relative_to(uploads_snapshot_dir)
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
finally:
shutil.rmtree(uploads_snapshot_dir, ignore_errors=True)
return archive_path, backup_name
+122 -1
View File
@@ -15,7 +15,7 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS,
)
CURRENT_SCHEMA_VERSION = "1.3.0"
CURRENT_SCHEMA_VERSION = "1.3.4"
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(
@@ -487,6 +590,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
@@ -738,8 +842,10 @@ 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 ''")
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
@@ -803,6 +909,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL")
database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL")
database.execute("UPDATE items SET is_quick_added = 0 WHERE is_quick_added IS NULL")
database.execute("UPDATE shopping_entries SET shopping_note = '' WHERE shopping_note IS NULL")
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
@@ -818,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
@@ -848,6 +961,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_entries (household_id, visibility, is_checked)
"""
)
database.execute("DROP INDEX IF EXISTS idx_shopping_entries_open_item")
database.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
ON shopping_entries (item_id, COALESCE(shopping_note, ''))
WHERE is_checked = 0
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
+201 -23
View File
@@ -9,7 +9,6 @@ import sqlite3
from flask import (
Blueprint,
after_this_request,
current_app,
flash,
g,
@@ -58,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,
@@ -1002,6 +1001,14 @@ def should_activate_shopping_need(needed_date: date, today: date | None = None)
return (today or date.today()) >= shopping_activation_date_for(needed_date)
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,
@@ -1083,14 +1090,16 @@ def add_to_shopping_list(
visibility_override: str | None = None,
needed_for_date: str | None = None,
needed_for_daypart_id: int | None = None,
shopping_note: str | None = None,
) -> bool:
item = get_item(item_id)
normalized_note = normalize_shopping_note(shopping_note)
existing = get_db().execute(
"""
SELECT id FROM shopping_entries
WHERE item_id = ? AND is_checked = 0
WHERE item_id = ? AND shopping_note = ? AND is_checked = 0
""",
(item_id,),
(item_id, normalized_note),
).fetchone()
if existing:
return False
@@ -1100,15 +1109,16 @@ def add_to_shopping_list(
get_db().execute(
"""
INSERT INTO shopping_entries (
household_id, owner_user_id, visibility, item_id, added_by, needed_for_date, needed_for_daypart_id
household_id, owner_user_id, visibility, item_id, shopping_note, added_by, needed_for_date, needed_for_daypart_id
)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
owner_user_id,
visibility,
item_id,
normalized_note,
user_id,
needed_for_date,
needed_for_daypart_id,
@@ -1151,6 +1161,7 @@ def ensure_item_or_missing_components_are_shopped(
needed_for_date: str | None = None,
needed_for_daypart_id: int | None = None,
source_item_id: int | None = None,
shopping_note: str | None = None,
) -> dict:
item = get_item(item_id)
if item["kind"] == "meal":
@@ -1220,6 +1231,7 @@ def ensure_item_or_missing_components_are_shopped(
visibility_override=visibility,
needed_for_date=needed_for_date,
needed_for_daypart_id=needed_for_daypart_id,
shopping_note=shopping_note,
)
return {
"added": added,
@@ -1290,6 +1302,97 @@ 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_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(
f"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
EXISTS(
SELECT 1
FROM shopping_entries
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
) AS is_on_shopping_list
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 IN ('food', 'shopping')
AND items.is_archived = 0
AND LOWER(items.name) = ?
AND {visible_clause('items')}
ORDER BY CASE items.kind WHEN 'food' THEN 0 ELSE 1 END, LOWER(items.name), items.id
LIMIT 1
""",
[normalized_name, *visible_params()],
).fetchone()
if row is None:
return 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"""
@@ -2978,8 +3081,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,
@@ -3710,18 +3812,17 @@ def backup_export():
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_size = Path(archive_path).stat().st_size
response = send_file(
archive_path,
as_attachment=True,
download_name=download_name,
mimetype="application/zip",
max_age=0,
)
response.content_length = archive_size
response.call_on_close(lambda: Path(archive_path).unlink(missing_ok=True))
return response
@main_bp.post("/settings/backup/restore")
@@ -4351,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
@@ -4387,6 +4489,56 @@ def remove_shopping_entry(entry_id: int) -> None:
get_db().commit()
@main_bp.post("/shopping/<int:entry_id>/note")
@login_required
def shopping_update_note(entry_id: int):
entry = get_db().execute(
f"""
SELECT shopping_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM shopping_entries
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Einkaufseintrag wurde nicht gefunden.", "error")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
except PermissionError as exc:
flash(str(exc), "error")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
duplicate = get_db().execute(
"""
SELECT id
FROM shopping_entries
WHERE item_id = ?
AND shopping_note = ?
AND is_checked = 0
AND id != ?
LIMIT 1
""",
(entry["item_id"], shopping_note, entry_id),
).fetchone()
if duplicate:
flash("Dieser Hinweis steht für das Lebensmittel schon auf der Einkaufsliste.", "info")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
get_db().execute(
"UPDATE shopping_entries SET shopping_note = ? WHERE id = ?",
(shopping_note, entry_id),
)
get_db().commit()
flash("Der Einkaufshinweis wurde gespeichert.", "success")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
@main_bp.post("/items/<int:item_id>/shopping/bought")
@login_required
def item_mark_bought(item_id: int):
@@ -4406,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"))
@@ -4438,30 +4593,50 @@ def item_remove_from_shopping(item_id: int):
def shopping_list():
if request.method == "POST":
selected_item_id = request.form.get("item_id", "").strip()
if not selected_item_id.isdigit():
flash("Bitte zuerst etwas auswählen.", "error")
else:
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 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_item_by_name(item_search)
if item is None:
flash("Bitte einen Treffer auswählen oder den Begriff als Lebensmittel bzw. Einkaufsartikel anlegen.", "error")
else:
flash("Bitte zuerst etwas auswählen.", "error")
if item is not None:
result = ensure_item_or_missing_components_are_shopped(
item["id"],
g.user["id"],
item["visibility"],
shopping_note=shopping_note,
)
if result["count"]:
flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success")
note_suffix = f" ({shopping_note})" if shopping_note else ""
flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}{note_suffix}.", "success")
elif result["scheduled_count"]:
flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info")
else:
flash("Dafür ist gerade nichts zusätzlich nötig.", "info")
except ValueError as exc:
flash(str(exc), "error")
flash("Dieser Einkaufseintrag steht so schon auf der Liste.", "info")
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
entries = fetch_shopping_entries()
upcoming_entries = fetch_upcoming_shopping_needs()
addable_items = fetch_items(include_archived=True, include_quick_added=True)
addable_items = [item for item in addable_items if item["kind"] == "food" and not item["is_on_shopping_list"]]
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(
@@ -4482,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")))
+3 -2
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',
@@ -167,6 +167,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL,
shopping_note TEXT NOT NULL DEFAULT '',
added_by INTEGER,
checked_by INTEGER,
needed_for_date TEXT,
@@ -183,7 +184,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
ON shopping_entries (item_id)
ON shopping_entries (item_id, COALESCE(shopping_note, ''))
WHERE is_checked = 0;
CREATE TABLE IF NOT EXISTS shopping_needs (
+490 -13
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(
@@ -941,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);
}
@@ -954,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;
@@ -1081,7 +1236,8 @@ h3 {
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;
gap: 0.9rem;
align-content: start;
@@ -1093,20 +1249,23 @@ h3 {
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);
justify-self: center;
aspect-ratio: 1;
border-radius: 24px;
}
.item-card-food .item-body-food {
.item-card-food .item-body-food,
.item-card-meal .item-body-meal {
justify-items: center;
text-align: center;
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;
font-size: 1.9rem;
line-height: 1.08;
@@ -1148,6 +1307,79 @@ 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-link {
text-decoration: none;
}
.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;
@@ -1173,7 +1405,9 @@ h3 {
}
.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;
visibility: visible;
transform: translateY(0);
@@ -1184,6 +1418,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,
@@ -1219,6 +1490,42 @@ h3 {
grid-column: 1 / -1;
}
.shopping-add-form {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(11rem, 0.8fr) auto;
gap: 0.8rem;
align-items: end;
}
.shopping-add-form .shopping-add-results {
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));
@@ -1244,6 +1551,15 @@ h3 {
padding: 0.85rem 0.95rem;
border-radius: 22px;
text-align: left;
background: color-mix(in srgb, var(--surface-strong) 86%, var(--accent-soft) 14%);
color: var(--text);
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,
@@ -1291,6 +1607,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;
@@ -1342,6 +1675,20 @@ h3 {
margin: 0;
}
.shopping-entry-note {
width: fit-content;
max-width: 100%;
margin: 0;
padding: 0.12rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--accent-soft) 56%, transparent 44%);
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
font-size: 0.82rem;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.shopping-entry-actions {
display: flex;
align-items: center;
@@ -1356,6 +1703,18 @@ h3 {
white-space: nowrap;
}
.shopping-entry-check-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.shopping-entry-check-mark {
font-size: 1rem;
line-height: 1;
}
.shopping-entry-close-form {
flex: 0 0 auto;
margin: 0;
@@ -1414,25 +1773,86 @@ h3 {
}
@media (max-width: 680px) {
.shopping-add-form {
grid-template-columns: 1fr;
}
.shopping-add-form button {
width: 100%;
}
.shopping-create-actions {
display: grid;
grid-template-columns: 1fr;
}
.shopping-entry-row {
align-items: stretch;
flex-direction: column;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.7rem;
}
.shopping-entry-open {
width: 100%;
min-width: 0;
}
.shopping-entry-main {
grid-template-columns: 56px minmax(0, 1fr);
gap: 0.8rem;
align-items: center;
}
.shopping-entry-visual,
.shopping-entry-fallback {
width: 56px;
height: 56px;
border-radius: 16px;
}
.shopping-entry-copy {
gap: 0.12rem;
}
.shopping-entry-copy strong {
font-size: 1rem;
}
.shopping-entry-copy .muted {
display: none;
}
.shopping-entry-note {
font-size: 0.78rem;
}
.shopping-entry-actions,
.shopping-entry-actions form,
.shopping-entry-actions button,
.shopping-entry-close-form {
width: 100%;
width: auto;
}
.shopping-entry-check-button {
min-width: 0;
padding: 0.75rem 0.9rem;
border-radius: 16px;
gap: 0;
}
.shopping-entry-check-label {
display: none;
}
.shopping-entry-check-mark {
font-size: 1.05rem;
}
.shopping-entry-close {
width: 100%;
border-radius: 18px;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
border-radius: 16px;
}
}
@@ -1448,6 +1868,31 @@ h3 {
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%);
}
[data-theme="dark"] button.shopping-add-card,
[data-theme="dark"] .shopping-add-card {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%),
color-mix(in srgb, var(--surface) 92%, #241f1d 8%)
);
color: var(--text);
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
box-shadow: none;
}
[data-theme="dark"] button.shopping-add-card:hover,
[data-theme="dark"] .shopping-add-card:hover {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-soft) 62%, #5a4840 38%),
color-mix(in srgb, var(--surface) 88%, #2f2724 12%)
);
}
[data-theme="dark"] .shopping-add-card-copy small {
color: color-mix(in srgb, var(--muted) 86%, white 14%);
}
[data-theme="dark"] .shopping-entry-card {
background: linear-gradient(
180deg,
@@ -1457,6 +1902,11 @@ h3 {
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.14) 42%);
}
[data-theme="dark"] .shopping-entry-note {
background: color-mix(in srgb, var(--accent-soft) 54%, rgba(32, 27, 25, 0.46) 46%);
color: color-mix(in srgb, var(--accent-strong) 82%, white 18%);
}
.auth-shell {
min-height: calc(100vh - 10rem);
display: grid;
@@ -1783,6 +2233,7 @@ legend {
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%);
color: var(--text);
}
.quick-select-card strong,
@@ -1800,6 +2251,20 @@ legend {
color: var(--muted);
}
[data-theme="dark"] .quick-select-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"] .quick-select-card small {
color: color-mix(in srgb, var(--muted) 86%, white 14%);
}
.inline-photo img {
width: min(220px, 100%);
border-radius: 18px;
@@ -2752,6 +3217,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");
@@ -2767,6 +3237,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");
@@ -3148,11 +3623,13 @@ legend {
grid-template-columns: 1fr;
}
.item-card-food {
.item-card-food,
.item-card-meal {
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);
}
@@ -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

+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

+74 -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,74 @@
});
};
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 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);
@@ -270,6 +341,8 @@
initPostFormScrollMemory();
initMobileSheet();
initFilterInputs();
initSelectedPreviews();
initCreateFromSearch();
initIosPullToRefresh();
initDialogs();
});
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "nouri-v1-0-0";
const CACHE_NAME = "nouri-v1-3-4";
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>
+74 -19
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 %}
@@ -132,8 +162,44 @@
</div>
</article>
{% else %}
<article class="item-card">
<div class="item-media">
<article class="item-card item-card-meal">
{% 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 %}
<img
src="{{ image_url(item.photo_filename, 'md') }}"
@@ -145,7 +211,12 @@
<div class="placeholder-tile">{{ item.name[:1] }}</div>
{% endif %}
</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">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
@@ -172,22 +243,6 @@
<p>{{ item.note }}</p>
{% endif %}
</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>
{% endif %}
{% endfor %}
+71 -28
View File
@@ -10,18 +10,32 @@
</section>
<section class="panel compact-form-panel">
<div class="stack-sections">
<form method="post" class="shopping-add-form">
{{ csrf_input() }}
<label>
Lebensmittel suchen
Lebensmittel oder Artikel suchen
<input
type="text"
placeholder="Nach Lebensmitteln suchen"
name="item_search"
id="shopping-item-search"
placeholder="Nach Lebensmitteln oder Artikeln suchen"
autocomplete="off"
data-filter-input
data-filter-target="#shopping-add-list"
data-filter-target="#shopping-food-options"
data-filter-limit="8"
data-filter-hide-empty
>
</label>
<div class="shopping-add-grid" id="shopping-add-list">
<label>
Einkaufshinweis
<input
type="text"
name="shopping_note"
maxlength="80"
placeholder="z. B. TK, Dose, frisch"
>
</label>
<div class="shopping-add-grid shopping-add-results" id="shopping-food-options">
{% for item in addable_items %}
{% set item_icon_class = {
'protein': 'icon-component-protein',
@@ -33,10 +47,16 @@
'seeds': 'icon-component-seeds',
'neutral': 'icon-component-neutral',
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
<form method="post" data-filter-label="{{ item.name|lower }} {{ item.base_type_label|lower }} {{ item.for_label|lower }}">
{{ csrf_input() }}
<input type="hidden" name="item_id" value="{{ item.id }}">
<button class="shopping-add-card" type="submit">
{% 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 }} {% if item.kind == 'shopping' %}einkaufsartikel artikel drogerie haushalt{% endif %}"
>
<span class="shopping-add-card-visual">
{% if item.photo_filename %}
<img
@@ -53,25 +73,19 @@
</span>
<span class="shopping-add-card-copy">
<strong>{{ item.name }}</strong>
<small>
{% if item.is_archived %}
Archiviert
{% elif item.is_quick_added %}
Unsortiert
{% elif item.is_home %}
Zuhause · trotzdem ergänzen
{% else %}
Gerade nicht da
{% endif %}
</small>
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
</span>
</button>
</form>
{% else %}
<p class="shopping-add-empty muted">Gerade ist nichts zusätzlich offen.</p>
{% 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>
{% if entries %}
@@ -93,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
@@ -119,6 +136,9 @@
</div>
<div class="shopping-entry-copy">
<strong>{{ entry.item_name }}</strong>
{% if entry.shopping_note %}
<p class="shopping-entry-note">{{ entry.shopping_note }}</p>
{% endif %}
{% if entry.needed_for_label %}
<p class="muted">
Für {{ entry.needed_for_label }}
@@ -131,7 +151,10 @@
<div class="shopping-entry-actions">
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
{{ csrf_input() }}
<button type="submit">Eingekauft</button>
<button type="submit" class="shopping-entry-check-button">
<span class="shopping-entry-check-mark" aria-hidden="true"></span>
<span class="shopping-entry-check-label">Eingekauft</span>
</button>
</form>
</div>
{% if entry.can_edit %}
@@ -150,9 +173,13 @@
<div>
<h3>{{ entry.item_name }}</h3>
<p>
{% if entry.needed_for_label %}
{% if entry.shopping_note %}
{{ entry.shopping_note }}
{% 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 %}
@@ -163,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) }}">
@@ -171,26 +198,42 @@
<button type="submit">Eingekauft</button>
</form>
{% if entry.can_edit %}
<form method="post" action="{{ url_for('main.shopping_update_note', entry_id=entry.id) }}">
{{ csrf_input() }}
<label>
Einkaufshinweis
<input
type="text"
name="shopping_note"
maxlength="80"
value="{{ entry.shopping_note }}"
placeholder="z. B. TK, Dose, frisch"
>
</label>
<button class="ghost-button" type="submit">Hinweis speichern</button>
</form>
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
{{ 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>