Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4babf93179 | |||
| aff40eff49 | |||
| 6b2c495cf2 |
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.3.2",
|
"version": "1.3.4",
|
||||||
"upstreamVersion": "1.3.2",
|
"upstreamVersion": "1.3.4",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
|
|||||||
).strip()
|
).strip()
|
||||||
if manifest_version:
|
if manifest_version:
|
||||||
return manifest_version
|
return manifest_version
|
||||||
return "1.3.2"
|
return "1.3.4"
|
||||||
|
|
||||||
|
|
||||||
def load_release_url() -> str:
|
def load_release_url() -> str:
|
||||||
|
|||||||
+111
-1
@@ -15,7 +15,7 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.3.2"
|
CURRENT_SCHEMA_VERSION = "1.3.4"
|
||||||
|
|
||||||
ANIMAL_HINTS = (
|
ANIMAL_HINTS = (
|
||||||
"huhn",
|
"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:
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
ensure_meta_table(database)
|
ensure_meta_table(database)
|
||||||
database.execute(
|
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", "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_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "items", "is_quick_added 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_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
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, "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 != ''
|
WHERE email IS NOT NULL AND email != ''
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
|
ON items (kind, name)
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
|
|||||||
+102
-23
@@ -57,7 +57,7 @@ from .constants import (
|
|||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
WEEK_TEMPLATE_NAME_SUGGESTIONS,
|
WEEK_TEMPLATE_NAME_SUGGESTIONS,
|
||||||
)
|
)
|
||||||
from .db import get_db
|
from .db import get_db, infer_food_flavor_profile, infer_food_profile
|
||||||
from .images import (
|
from .images import (
|
||||||
allowed_image_file,
|
allowed_image_file,
|
||||||
save_photo_with_variants,
|
save_photo_with_variants,
|
||||||
@@ -1005,6 +1005,10 @@ def normalize_shopping_note(value: str | None) -> str:
|
|||||||
return " ".join((value or "").strip().split())[:80]
|
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(
|
def schedule_shopping_need(
|
||||||
*,
|
*,
|
||||||
item_id: int,
|
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]
|
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:
|
def find_shopping_item_by_name(name: str) -> dict | None:
|
||||||
normalized_name = name.strip().lower()
|
normalized_name = normalize_new_item_name(name).lower()
|
||||||
if not normalized_name:
|
if not normalized_name:
|
||||||
return None
|
return None
|
||||||
row = get_db().execute(
|
row = get_db().execute(
|
||||||
@@ -1317,11 +1321,11 @@ def find_shopping_food_by_name(name: str) -> dict | None:
|
|||||||
FROM items
|
FROM items
|
||||||
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
||||||
LEFT JOIN users AS target ON target.id = items.target_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 items.is_archived = 0
|
||||||
AND LOWER(items.name) = ?
|
AND LOWER(items.name) = ?
|
||||||
AND {visible_clause('items')}
|
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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
[normalized_name, *visible_params()],
|
[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]
|
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():
|
def fetch_shopping_entries():
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -4390,18 +4452,19 @@ def mark_shopping_entry_checked(entry_id: int) -> dict:
|
|||||||
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
|
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
|
||||||
(g.user["id"], entry_id),
|
(g.user["id"], entry_id),
|
||||||
)
|
)
|
||||||
get_db().execute(
|
if item["kind"] != "shopping":
|
||||||
"""
|
get_db().execute(
|
||||||
UPDATE items
|
"""
|
||||||
SET availability_state = 'home',
|
UPDATE items
|
||||||
is_archived = 0,
|
SET availability_state = 'home',
|
||||||
is_quick_added = 0,
|
is_archived = 0,
|
||||||
updated_by = ?,
|
is_quick_added = 0,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_by = ?,
|
||||||
WHERE id = ?
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""",
|
WHERE id = ?
|
||||||
(g.user["id"], item["id"]),
|
""",
|
||||||
)
|
(g.user["id"], item["id"]),
|
||||||
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@@ -4495,7 +4558,10 @@ def item_mark_bought(item_id: int):
|
|||||||
except (ValueError, PermissionError) as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||||
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
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"))
|
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||||
|
|
||||||
|
|
||||||
@@ -4528,17 +4594,24 @@ def shopping_list():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
selected_item_id = request.form.get("item_id", "").strip()
|
selected_item_id = request.form.get("item_id", "").strip()
|
||||||
item_search = request.form.get("item_search", "").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"))
|
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
|
||||||
item = None
|
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:
|
try:
|
||||||
item = get_item(int(selected_item_id))
|
item = get_item(int(selected_item_id))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
elif item_search:
|
elif item_search:
|
||||||
item = find_shopping_food_by_name(item_search)
|
item = find_shopping_item_by_name(item_search)
|
||||||
if item is None:
|
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:
|
else:
|
||||||
flash("Bitte zuerst etwas auswählen.", "error")
|
flash("Bitte zuerst etwas auswählen.", "error")
|
||||||
|
|
||||||
@@ -4560,7 +4633,10 @@ def shopping_list():
|
|||||||
|
|
||||||
entries = fetch_shopping_entries()
|
entries = fetch_shopping_entries()
|
||||||
upcoming_entries = fetch_upcoming_shopping_needs()
|
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()
|
household_settings = get_household_settings()
|
||||||
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -4581,7 +4657,10 @@ def shopping_check(entry_id: int):
|
|||||||
except (ValueError, PermissionError) as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||||
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
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")))
|
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
owner_user_id INTEGER,
|
owner_user_id INTEGER,
|
||||||
target_user_id INTEGER,
|
target_user_id INTEGER,
|
||||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
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,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
base_type TEXT NOT NULL DEFAULT 'neutral',
|
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
|||||||
+174
-7
@@ -1082,6 +1082,16 @@ h3 {
|
|||||||
color: #ece8e4;
|
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 {
|
.status-unsorted {
|
||||||
background: rgba(184, 161, 108, 0.18);
|
background: rgba(184, 161, 108, 0.18);
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1105,10 @@ h3 {
|
|||||||
background: var(--lilac-soft);
|
background: var(--lilac-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-shopping {
|
||||||
|
background: color-mix(in srgb, var(--mint-soft) 70%, var(--sky-soft) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1222,7 +1236,8 @@ h3 {
|
|||||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food {
|
.item-card-food,
|
||||||
|
.item-card-meal {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -1234,20 +1249,23 @@ h3 {
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-media-food {
|
.item-card-food .item-media-food,
|
||||||
|
.item-card-meal .item-media-meal {
|
||||||
width: min(100%, 170px);
|
width: min(100%, 170px);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-body-food {
|
.item-card-food .item-body-food,
|
||||||
|
.item-card-meal .item-body-meal {
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-body-food h2 {
|
.item-card-food .item-body-food h2,
|
||||||
|
.item-card-meal .item-body-meal h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.9rem;
|
font-size: 1.9rem;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
@@ -1289,6 +1307,79 @@ h3 {
|
|||||||
transform: translateY(-1px);
|
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 {
|
.item-card-hover-meta {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1314,7 +1405,9 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food:hover .item-card-hover-meta,
|
.item-card-food:hover .item-card-hover-meta,
|
||||||
.item-card-food:focus-within .item-card-hover-meta {
|
.item-card-food:focus-within .item-card-hover-meta,
|
||||||
|
.item-card-meal:hover .item-card-hover-meta,
|
||||||
|
.item-card-meal:focus-within .item-card-hover-meta {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -1325,6 +1418,43 @@ h3 {
|
|||||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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 {
|
[data-theme="dark"] .item-card-hover-meta {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
@@ -1371,6 +1501,31 @@ h3 {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.shopping-add-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
@@ -1626,6 +1781,11 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shopping-create-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.shopping-entry-row {
|
.shopping-entry-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
@@ -3057,6 +3217,11 @@ legend {
|
|||||||
mask-image: url("../icons/fa/house.svg");
|
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 {
|
.icon-utensils {
|
||||||
-webkit-mask-image: url("../icons/fa/utensils.svg");
|
-webkit-mask-image: url("../icons/fa/utensils.svg");
|
||||||
mask-image: url("../icons/fa/utensils.svg");
|
mask-image: url("../icons/fa/utensils.svg");
|
||||||
@@ -3458,11 +3623,13 @@ legend {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food {
|
.item-card-food,
|
||||||
|
.item-card-meal {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-food .item-media-food {
|
.item-card-food .item-media-food,
|
||||||
|
.item-card-meal .item-media-meal {
|
||||||
width: min(100%, 156px);
|
width: min(100%, 156px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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 initIosPullToRefresh = () => {
|
||||||
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
||||||
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
@@ -323,6 +342,7 @@
|
|||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
initSelectedPreviews();
|
initSelectedPreviews();
|
||||||
|
initCreateFromSearch();
|
||||||
initIosPullToRefresh();
|
initIosPullToRefresh();
|
||||||
initDialogs();
|
initDialogs();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "nouri-v1-3-2";
|
const CACHE_NAME = "nouri-v1-3-4";
|
||||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
"/static/css/styles.css",
|
"/static/css/styles.css",
|
||||||
|
|||||||
@@ -86,6 +86,36 @@
|
|||||||
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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">
|
<div class="item-media item-media-food">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
@@ -132,8 +162,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<article class="item-card">
|
<article class="item-card item-card-meal">
|
||||||
<div class="item-media">
|
{% if item.can_edit %}
|
||||||
|
<a class="item-card-cover-link" href="{{ url_for('main.item_edit', item_id=item.id) }}">
|
||||||
|
<span class="sr-only">{{ item.name }} bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<form
|
||||||
|
class="item-card-archive-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('main.item_archive', item_id=item.id) }}"
|
||||||
|
onsubmit="return confirm('Willst du diese Mahlzeitenidee wirklich archivieren?');"
|
||||||
|
>
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-quick-actions">
|
||||||
|
<a
|
||||||
|
class="item-card-icon-button item-card-icon-link"
|
||||||
|
href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}"
|
||||||
|
aria-label="{{ item.name }} im Tagesplan öffnen"
|
||||||
|
title="Im Tagesplan öffnen"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-calendar"></span>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ url_for('main.item_remove_from_shopping' if item.is_on_shopping_list else 'main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button
|
||||||
|
class="item-card-icon-button {% if item.is_on_shopping_list %}is-active{% else %}is-inactive{% endif %}"
|
||||||
|
type="submit"
|
||||||
|
aria-label="{% if item.is_on_shopping_list %}{{ item.name }} von der Einkaufsliste entfernen{% else %}{{ item.name }} auf die Einkaufsliste setzen{% endif %}"
|
||||||
|
title="{% if item.is_on_shopping_list %}Auf Einkaufsliste{% else %}Nicht auf Einkaufsliste{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-media item-media-meal">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
<img
|
<img
|
||||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
@@ -145,7 +211,12 @@
|
|||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
|
||||||
|
<div class="item-body item-body-meal">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-card-hover-meta" aria-hidden="true">
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ item.visibility_label }}</span>
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
@@ -172,22 +243,6 @@
|
|||||||
<p>{{ item.note }}</p>
|
<p>{{ item.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
|
||||||
{% if item.can_edit %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
|
||||||
<form class="primary-action" method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button type="submit">Auf Einkaufsliste</button>
|
|
||||||
</form>
|
|
||||||
{% if item.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button class="ghost-button" type="submit">Archivieren</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
<form method="post" class="shopping-add-form">
|
<form method="post" class="shopping-add-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<label>
|
<label>
|
||||||
Lebensmittel suchen
|
Lebensmittel oder Artikel suchen
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="item_search"
|
name="item_search"
|
||||||
placeholder="Nach Lebensmitteln suchen"
|
id="shopping-item-search"
|
||||||
|
placeholder="Nach Lebensmitteln oder Artikeln suchen"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
data-filter-input
|
data-filter-input
|
||||||
data-filter-target="#shopping-food-options"
|
data-filter-target="#shopping-food-options"
|
||||||
@@ -46,12 +47,15 @@
|
|||||||
'seeds': 'icon-component-seeds',
|
'seeds': 'icon-component-seeds',
|
||||||
'neutral': 'icon-component-neutral',
|
'neutral': 'icon-component-neutral',
|
||||||
}.get(item.primary_builder_key or item.base_type, '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
|
<button
|
||||||
class="shopping-add-card"
|
class="shopping-add-card"
|
||||||
type="submit"
|
type="submit"
|
||||||
name="item_id"
|
name="item_id"
|
||||||
value="{{ 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">
|
<span class="shopping-add-card-visual">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
@@ -69,11 +73,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="shopping-add-card-copy">
|
<span class="shopping-add-card-copy">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<small>{{ item.availability_label }}</small>
|
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
<button type="submit">Auf die Liste</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -97,6 +107,9 @@
|
|||||||
'seeds': 'icon-component-seeds',
|
'seeds': 'icon-component-seeds',
|
||||||
'neutral': 'icon-component-neutral',
|
'neutral': 'icon-component-neutral',
|
||||||
}.get(entry.primary_builder_key or entry.base_type, '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">
|
<article class="shopping-entry-card">
|
||||||
<div class="shopping-entry-row">
|
<div class="shopping-entry-row">
|
||||||
<div
|
<div
|
||||||
@@ -165,6 +178,8 @@
|
|||||||
{% elif entry.needed_for_label %}
|
{% elif entry.needed_for_label %}
|
||||||
Für {{ entry.needed_for_label }}
|
Für {{ entry.needed_for_label }}
|
||||||
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||||
|
{% elif entry.item_kind == 'shopping' %}
|
||||||
|
Einkaufsartikel
|
||||||
{% elif entry.is_home %}
|
{% elif entry.is_home %}
|
||||||
Zuhause vorhanden
|
Zuhause vorhanden
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -175,7 +190,7 @@
|
|||||||
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
|
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-entry-dialog-actions">
|
<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>
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=entry.item_id) }}">Bearbeiten</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
@@ -201,21 +216,23 @@
|
|||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
||||||
</form>
|
</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) }}">
|
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=entry.item_id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Nicht mehr da</button>
|
<button class="ghost-button" type="submit">Nicht mehr da</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% elif entry.item_kind != 'shopping' %}
|
||||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
|
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Als zuhause markieren</button>
|
<button class="ghost-button" type="submit">Als zuhause markieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
|
{% if entry.item_kind != 'shopping' %}
|
||||||
{{ csrf_input() }}
|
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
|
||||||
<button class="ghost-button" type="submit">Archivieren</button>
|
{{ csrf_input() }}
|
||||||
</form>
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user