1 Commits

Author SHA1 Message Date
hnzio d8b56e6b67 release nouri 0.4.0 templates suggestions and mobile sheet 2026-04-12 16:00:00 +02:00
28 changed files with 2651 additions and 492 deletions
+2 -2
View File
@@ -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": "0.3.0", "version": "0.4.0",
"upstreamVersion": "0.3.0", "upstreamVersion": "0.4.0",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+9 -3
View File
@@ -2,7 +2,7 @@
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
## Merkmale in Version 0.3 ## Merkmale in Version 0.4
- Lebensmittel und Mahlzeitenideen anlegen - Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen - Fotos lokal hochladen
@@ -17,6 +17,12 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
- Profilseite und Passwortänderung - Profilseite und Passwortänderung
- kleine Admin-Verwaltung für Nutzer - kleine Admin-Verwaltung für Nutzer
- kompaktere mobile Navigation mit Bottom-Bar - kompaktere mobile Navigation mit Bottom-Bar
- Tagesvorlagen und Wochenvorlagen
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine
- sanfte Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
- globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
- Mobile-Mehr-Menü als Sheet statt eigener Seite
## Lokal starten ## Lokal starten
@@ -39,9 +45,9 @@ Wichtige Umgebungsvariablen:
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
## Migration von 0.2 auf 0.3 ## Migration von 0.3 auf 0.4
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite: Haushalt, Rollen, Aktiv-Status und Sichtbarkeit (`persönlich` oder `Für alle`) werden ergänzt. Vorhandene 0.2-Daten bleiben erhalten und werden automatisch einem gemeinsamen Haushaltskontext zugeordnet. Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Vorlagen, kleine Pakete, Kategorien pro Haushalt, Zielnutzer an Lebensmitteln und zusätzliche Einkaufs-Kontexte werden ergänzt. Vorhandene 0.3-Daten bleiben erhalten und werden weiterverwendet.
## Cloudron-Hinweis ## Cloudron-Hinweis
+2 -2
View File
@@ -11,8 +11,8 @@ from . import db
from .admin import admin_bp from .admin import admin_bp
from .auth import auth_bp from .auth import auth_bp
from .constants import ( from .constants import (
CATEGORIES,
DAYPARTS, DAYPARTS,
DEFAULT_CATEGORIES,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
ROLE_LABELS, ROLE_LABELS,
@@ -77,7 +77,7 @@ def create_app() -> Flask:
return { return {
"item_kind_labels": ITEM_KIND_LABELS, "item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
"category_suggestions": CATEGORIES, "category_suggestions": DEFAULT_CATEGORIES,
"daypart_suggestions": DAYPARTS, "daypart_suggestions": DAYPARTS,
"visibility_labels": VISIBILITY_LABELS, "visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS,
+83 -1
View File
@@ -4,7 +4,7 @@ from flask import Blueprint, flash, g, redirect, render_template, request, url_f
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
from .constants import ROLE_LABELS from .constants import DEFAULT_CATEGORIES, ROLE_LABELS
from .db import get_db from .db import get_db
@@ -26,6 +26,18 @@ def get_household_user(user_id: int):
return user return user
def fetch_household_categories():
return get_db().execute(
"""
SELECT *
FROM household_categories
WHERE household_id = ?
ORDER BY is_active DESC, sort_order, LOWER(name)
""",
(g.user["household_id"],),
).fetchall()
@admin_bp.get("/users") @admin_bp.get("/users")
@admin_required @admin_required
def user_list(): def user_list():
@@ -178,3 +190,73 @@ def user_edit(user_id: int):
flash(error, "error") flash(error, "error")
return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS) return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS)
@admin_bp.route("/categories", methods=("GET", "POST"))
@admin_required
def category_settings():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
flash("Bitte einen Kategorienamen eintragen.", "error")
else:
existing = get_db().execute(
"""
SELECT id
FROM household_categories
WHERE household_id = ? AND LOWER(name) = LOWER(?)
""",
(g.user["household_id"], name),
).fetchone()
if existing:
get_db().execute(
"UPDATE household_categories SET is_active = 1 WHERE id = ?",
(existing["id"],),
)
flash("Die Kategorie ist wieder aktiv.", "success")
else:
sort_row = get_db().execute(
"SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM household_categories WHERE household_id = ?",
(g.user["household_id"],),
).fetchone()
get_db().execute(
"""
INSERT INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
""",
(g.user["household_id"], name, int(sort_row["max_sort"]) + 10),
)
flash("Die Kategorie wurde ergänzt.", "success")
get_db().commit()
return redirect(url_for("admin.category_settings"))
return render_template(
"admin/categories.html",
categories=fetch_household_categories(),
default_categories=DEFAULT_CATEGORIES,
)
@admin_bp.post("/categories/<int:category_id>/toggle")
@admin_required
def category_toggle(category_id: int):
category = get_db().execute(
"""
SELECT *
FROM household_categories
WHERE id = ? AND household_id = ?
""",
(category_id, g.user["household_id"]),
).fetchone()
if category is None:
flash("Die Kategorie wurde nicht gefunden.", "error")
return redirect(url_for("admin.category_settings"))
new_state = 0 if category["is_active"] else 1
get_db().execute(
"UPDATE household_categories SET is_active = ? WHERE id = ?",
(new_state, category_id),
)
get_db().commit()
flash("Die Kategorie wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings"))
+25 -2
View File
@@ -7,7 +7,7 @@ DAYPARTS = [
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, {"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
] ]
CATEGORIES = [ DEFAULT_CATEGORIES = [
"Brot & Getreide", "Brot & Getreide",
"Milchprodukt", "Milchprodukt",
"Obst", "Obst",
@@ -42,7 +42,7 @@ ROLE_LABELS = {
} }
VISIBILITY_LABELS = { VISIBILITY_LABELS = {
"shared": "Für alle", "shared": "Gemeinsam",
"personal": "Persönlich", "personal": "Persönlich",
} }
@@ -50,3 +50,26 @@ VISIBILITY_DESCRIPTIONS = {
"shared": "Gemeinsam im Haushalt sichtbar und nutzbar.", "shared": "Gemeinsam im Haushalt sichtbar und nutzbar.",
"personal": "Nur für dich sichtbar und planbar.", "personal": "Nur für dich sichtbar und planbar.",
} }
DAY_TEMPLATE_NAME_SUGGESTIONS = [
"Ruhiger Tag",
"Einfacher Bürotag",
"Schwieriger Tag",
"Standard-Frühstückstag",
"Tag mit wenig Energie",
]
WEEK_TEMPLATE_NAME_SUGGESTIONS = [
"Standardwoche",
"Büro-Woche",
"Leichte Woche",
"Woche mit wenig Energie",
"Frühstücks-Woche",
]
ITEM_SET_NAME_SUGGESTIONS = [
"Schnelles Frühstück",
"Sicherer Snack",
"Einfaches Abendessen",
"Einkauf für zwei Tage",
]
+65 -39
View File
@@ -8,7 +8,7 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .constants import DAYPARTS from .constants import DAYPARTS, DEFAULT_CATEGORIES
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -33,6 +33,14 @@ def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]:
return {row["name"] for row in rows} return {row["name"] for row in rows}
def table_exists(database: sqlite3.Connection, table_name: str) -> bool:
row = database.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return row is not None
def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None: def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None:
column_name = definition.split()[0] column_name = definition.split()[0]
if column_name not in table_columns(database, table_name): if column_name not in table_columns(database, table_name):
@@ -50,31 +58,41 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
""" """
) )
existing_tables = { database.execute(
row["name"] """
for row in database.execute( CREATE TABLE IF NOT EXISTS household_categories (
"SELECT name FROM sqlite_master WHERE type = 'table'" id INTEGER PRIMARY KEY AUTOINCREMENT,
).fetchall() household_id INTEGER NOT NULL,
} name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name)
)
"""
)
if "users" in existing_tables: if table_exists(database, "users"):
add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "email TEXT")
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1") add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "users", "updated_at TEXT") add_column_if_missing(database, "users", "updated_at TEXT")
if "items" in existing_tables: if table_exists(database, "items"):
add_column_if_missing(database, "items", "household_id INTEGER") add_column_if_missing(database, "items", "household_id INTEGER")
add_column_if_missing(database, "items", "owner_user_id INTEGER") add_column_if_missing(database, "items", "owner_user_id INTEGER")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
if "shopping_entries" in existing_tables: if table_exists(database, "shopping_entries"):
add_column_if_missing(database, "shopping_entries", "household_id INTEGER") 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", "owner_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if "plan_entries" in existing_tables: if table_exists(database, "plan_entries"):
add_column_if_missing(database, "plan_entries", "household_id INTEGER") add_column_if_missing(database, "plan_entries", "household_id INTEGER")
add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER") add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
@@ -91,9 +109,11 @@ def ensure_default_household(database: sqlite3.Connection) -> int:
"INSERT INTO households (name) VALUES (?)", "INSERT INTO households (name) VALUES (?)",
("Unser Haushalt",), ("Unser Haushalt",),
) )
return int( return int(database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"])
database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"]
)
def household_ids(database: sqlite3.Connection) -> list[int]:
return [int(row["id"]) for row in database.execute("SELECT id FROM households ORDER BY id").fetchall()]
def first_user_id(database: sqlite3.Connection) -> int | None: def first_user_id(database: sqlite3.Connection) -> int | None:
@@ -101,6 +121,18 @@ def first_user_id(database: sqlite3.Connection) -> int | None:
return int(row["id"]) if row else None return int(row["id"]) if row else None
def sync_default_categories(database: sqlite3.Connection) -> None:
for household_id in household_ids(database):
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute(
"""
INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
""",
(household_id, name, sort_order),
)
def ensure_schema_upgrades(database: sqlite3.Connection) -> None: def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "email TEXT")
@@ -113,18 +145,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
"UPDATE users SET household_id = ? WHERE household_id IS NULL", "UPDATE users SET household_id = ? WHERE household_id IS NULL",
(default_household_id,), (default_household_id,),
) )
database.execute( database.execute("UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''")
"UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''", database.execute("UPDATE users SET is_active = 1 WHERE is_active IS NULL")
) database.execute("UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''")
database.execute( database.execute("UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)")
"UPDATE users SET is_active = 1 WHERE is_active IS NULL",
)
database.execute(
"UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''",
)
database.execute(
"UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)"
)
admin_row = database.execute( admin_row = database.execute(
"SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1" "SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1"
@@ -132,16 +156,16 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
if admin_row is None: if admin_row is None:
first_id = first_user_id(database) first_id = first_user_id(database)
if first_id is not None: if first_id is not None:
database.execute( database.execute("UPDATE users SET role = 'admin' WHERE id = ?", (first_id,))
"UPDATE users SET role = 'admin' WHERE id = ?",
(first_id,),
)
default_owner_id = first_user_id(database) default_owner_id = first_user_id(database)
for table_name in ("items", "shopping_entries", "plan_entries"): for table_name in ("items", "shopping_entries", "plan_entries"):
add_column_if_missing(database, table_name, "household_id INTEGER") add_column_if_missing(database, table_name, "household_id INTEGER")
add_column_if_missing(database, table_name, "owner_user_id INTEGER") add_column_if_missing(database, table_name, "owner_user_id INTEGER")
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if default_owner_id is not None: if default_owner_id is not None:
database.execute( database.execute(
@@ -175,15 +199,11 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
(default_household_id, default_owner_id), (default_household_id, default_owner_id),
) )
else: else:
database.execute( database.execute("UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
"UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" database.execute("UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
) database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
database.execute(
"UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" sync_default_categories(database)
)
database.execute(
"UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
)
database.execute( database.execute(
""" """
@@ -198,6 +218,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON items (household_id, visibility, availability_state) ON items (household_id, visibility, availability_state)
""" """
) )
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id)
"""
)
database.execute( database.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
+1376 -320
View File
File diff suppressed because it is too large Load Diff
+102 -1
View File
@@ -24,6 +24,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (email) ON users (email)
WHERE email IS NOT NULL AND email != ''; WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name),
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS dayparts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
@@ -35,6 +46,7 @@ CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER, household_id INTEGER,
owner_user_id INTEGER, owner_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')),
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -48,6 +60,7 @@ CREATE TABLE IF NOT EXISTS items (
updated_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 (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, 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 (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
); );
@@ -76,6 +89,8 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
item_id INTEGER NOT NULL, item_id INTEGER NOT NULL,
added_by INTEGER, added_by INTEGER,
checked_by INTEGER, checked_by INTEGER,
needed_for_date TEXT,
needed_for_daypart_id INTEGER,
is_checked INTEGER NOT NULL DEFAULT 0, is_checked INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
checked_at TEXT, checked_at TEXT,
@@ -83,7 +98,8 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
@@ -108,12 +124,88 @@ CREATE TABLE IF NOT EXISTS plan_entries (
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS day_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
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
);
CREATE TABLE IF NOT EXISTS day_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_template_id INTEGER NOT NULL,
daypart_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE,
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS week_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
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
);
CREATE TABLE IF NOT EXISTS week_template_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_template_id INTEGER NOT NULL,
weekday_index INTEGER NOT NULL,
day_template_id INTEGER NOT NULL,
UNIQUE (week_template_id, weekday_index),
FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS item_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
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
);
CREATE TABLE IF NOT EXISTS item_set_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_set_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
UNIQUE (item_set_id, item_id),
FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_items_kind_name CREATE INDEX IF NOT EXISTS idx_items_kind_name
ON items (kind, name); ON items (kind, name);
CREATE INDEX IF NOT EXISTS idx_items_household_visibility CREATE INDEX IF NOT EXISTS idx_items_household_visibility
ON items (household_id, visibility, availability_state); ON items (household_id, visibility, availability_state);
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id);
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
ON item_dayparts (daypart_id, item_id); ON item_dayparts (daypart_id, item_id);
@@ -125,3 +217,12 @@ ON plan_entries (household_id, visibility, plan_date);
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
ON shopping_entries (household_id, visibility, is_checked); ON shopping_entries (household_id, visibility, is_checked);
CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
ON day_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility
ON week_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility
ON item_sets (household_id, visibility, name);
+192 -2
View File
@@ -67,6 +67,10 @@ body.has-mobile-nav {
padding-bottom: 6rem; padding-bottom: 6rem;
} }
body.sheet-open {
overflow: hidden;
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -256,6 +260,11 @@ h3,
display: none; display: none;
} }
.mobile-sheet-backdrop[hidden],
.mobile-more-sheet[hidden] {
display: none;
}
.content { .content {
display: grid; display: grid;
gap: 1.2rem; gap: 1.2rem;
@@ -576,6 +585,12 @@ h3 {
gap: 1rem; gap: 1rem;
} }
.template-library-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.stack-form label, .stack-form label,
.planner-entry-form label, .planner-entry-form label,
.filter-form label, .filter-form label,
@@ -640,6 +655,37 @@ legend {
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.check-option[hidden],
.quick-select-card[hidden] {
display: none;
}
.quick-select-card {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.85rem 0.95rem;
min-width: 220px;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%);
}
.quick-select-card strong,
.template-list-card strong {
display: block;
}
.quick-select-card small,
.template-list-card p,
.template-list-card small,
.hint-chip,
.suggestion-card p,
.suggestion-card small,
.week-template-row p {
color: var(--muted);
}
.inline-photo img { .inline-photo img {
width: min(220px, 100%); width: min(220px, 100%);
border-radius: 18px; border-radius: 18px;
@@ -739,6 +785,10 @@ legend {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.template-search-row {
margin-bottom: 0.9rem;
}
.quick-add-row form { .quick-add-row form {
margin: 0; margin: 0;
} }
@@ -769,6 +819,49 @@ legend {
background: color-mix(in srgb, var(--surface) 88%, #fff 12%); background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
} }
.template-card,
.template-list-card,
.suggestion-card {
padding: 0.95rem 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
}
.template-list-card,
.week-template-row {
display: grid;
gap: 0.9rem;
}
.week-template-row {
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.34);
}
.hint-list {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.hint-chip {
margin: 0;
padding: 0.85rem 1rem;
border-radius: 16px;
background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%);
border: 1px solid var(--line);
}
.suggestion-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.planner-entry-top { .planner-entry-top {
align-items: flex-start; align-items: flex-start;
} }
@@ -915,6 +1008,71 @@ legend {
mask-image: url("../icons/fa/sparkles.svg"); mask-image: url("../icons/fa/sparkles.svg");
} }
.icon-layer-group {
-webkit-mask-image: url("../icons/fa/layer-group.svg");
mask-image: url("../icons/fa/layer-group.svg");
}
.icon-ellipsis {
-webkit-mask-image: url("../icons/fa/ellipsis.svg");
mask-image: url("../icons/fa/ellipsis.svg");
}
.mobile-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 24;
background: rgba(33, 29, 28, 0.22);
backdrop-filter: blur(6px);
}
.mobile-more-sheet {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: 5.9rem;
z-index: 25;
padding: 1rem;
border-radius: 24px;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
backdrop-filter: blur(24px) saturate(1.1);
}
.mobile-sheet-head,
.mobile-sheet-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.mobile-sheet-head small {
color: var(--muted);
}
.mobile-sheet-links {
display: grid;
gap: 0.45rem;
margin: 1rem 0;
}
.mobile-sheet-links a {
padding: 0.9rem 1rem;
border-radius: 16px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
}
.mobile-sheet-actions {
flex-wrap: wrap;
}
.mobile-sheet-actions > * {
flex: 1 1 180px;
}
@media (max-width: 1080px) { @media (max-width: 1080px) {
.site-header, .site-header,
.hero, .hero,
@@ -932,6 +1090,7 @@ legend {
.stats-grid, .stats-grid,
.two-column, .two-column,
.template-library-grid,
.inline-form, .inline-form,
.planner-entry-form, .planner-entry-form,
.planner-entry-form-wide, .planner-entry-form-wide,
@@ -1014,7 +1173,8 @@ legend {
.mini-card-grid, .mini-card-grid,
.week-mini-grid, .week-mini-grid,
.week-overview-grid, .week-overview-grid,
.more-link-grid { .more-link-grid,
.template-library-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1079,7 +1239,20 @@ legend {
font-size: 0.78rem; font-size: 0.78rem;
} }
.mobile-bottom-nav a.active { .mobile-nav-button {
display: grid;
justify-items: center;
gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 0.78rem;
}
.mobile-bottom-nav a.active,
.mobile-nav-button.is-open {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--text); color: var(--text);
} }
@@ -1088,4 +1261,21 @@ legend {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
.mobile-profile-link {
display: inline-flex;
padding: 0.35rem;
background: transparent;
}
.mobile-profile-link .mobile-profile-avatar {
width: 2.15rem;
height: 2.15rem;
}
.mobile-sheet-head,
.mobile-sheet-actions,
.week-template-row {
align-items: flex-start;
}
} }
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.6 2 8l10 5.4L22 8 12 2.6Zm-7.9 8.6L2 12.4l10 5.4 10-5.4-2.1-1.2L12 15.4 4.1 11.2Zm0 4.4L2 16.8l10 5.4 10-5.4-2.1-1.2L12 19.8 4.1 15.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

+67
View File
@@ -0,0 +1,67 @@
(() => {
const initMobileSheet = () => {
const sheet = document.querySelector("[data-mobile-sheet]");
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
if (!sheet || !backdrop || !openButtons.length) return;
const closeSheet = () => {
sheet.hidden = true;
backdrop.hidden = true;
document.body.classList.remove("sheet-open");
openButtons.forEach((button) => button.classList.remove("is-open"));
};
const openSheet = () => {
sheet.hidden = false;
backdrop.hidden = false;
document.body.classList.add("sheet-open");
openButtons.forEach((button) => button.classList.add("is-open"));
};
openButtons.forEach((button) => {
button.addEventListener("click", openSheet);
});
closeButtons.forEach((button) => {
button.addEventListener("click", closeSheet);
});
backdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSheet();
}
});
sheet.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeSheet);
});
};
const initFilterInputs = () => {
document.querySelectorAll("[data-filter-input]").forEach((input) => {
const listSelector = input.getAttribute("data-filter-target");
if (!listSelector) return;
const container = document.querySelector(listSelector);
if (!container) return;
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
items.forEach((item) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
item.hidden = Boolean(term) && !haystack.includes(term);
});
};
input.addEventListener("input", applyFilter);
applyFilter();
});
};
document.addEventListener("DOMContentLoaded", () => {
initMobileSheet();
initFilterInputs();
});
})();
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Kategorien | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kategorien</p>
<h1>Kategorien global anpassen</h1>
<p class="lead">Hier pflegt ihr die Auswahl für Lebensmittel und Mahlzeiten. Bestehende Einträge bleiben auch dann erhalten, wenn eine Kategorie später pausiert wird.</p>
</div>
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zur Nutzerverwaltung</a>
</section>
<section class="panel compact-form-panel">
<form method="post" class="inline-form">
{{ csrf_input() }}
<label class="wide">
Neue Kategorie
<input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs">
</label>
<button type="submit">Kategorie ergänzen</button>
</form>
</section>
<section class="stack-list">
{% for category in categories %}
<article class="list-row stacked-mobile">
<div>
<strong>{{ category.name }}</strong>
<p class="muted">{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}</p>
<div class="chip-row">
{% if category.is_active %}
<span class="chip status-home">Aktiv</span>
{% else %}
<span class="chip status-archived">Pausiert</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button>
</form>
</div>
</article>
{% endfor %}
</section>
{% endblock %}
+5 -2
View File
@@ -5,9 +5,12 @@
<div> <div>
<p class="eyebrow">Nutzer verwalten</p> <p class="eyebrow">Nutzer verwalten</p>
<h1>Haushaltszugänge ruhig pflegen</h1> <h1>Haushaltszugänge ruhig pflegen</h1>
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen und Zugänge bei Bedarf pausieren.</p> <p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen, Zugänge pausieren und die gemeinsamen Kategorien pflegen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
<a class="button secondary" href="{{ url_for('admin.category_settings') }}">Kategorien</a>
</div> </div>
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
</section> </section>
<section class="stack-list"> <section class="stack-list">
+1
View File
@@ -54,6 +54,7 @@
<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>
<span class="chip">{{ item.for_label }}</span>
</div> </div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p> <p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.dayparts %} {% if item.dayparts %}
+44 -12
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script> <script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script> <script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script>
</head> </head>
<body class="{% if g.user %}has-mobile-nav{% endif %}"> <body class="{% if g.user %}has-mobile-nav{% endif %}">
<div class="page-shell"> <div class="page-shell">
@@ -27,11 +28,12 @@
<nav class="site-nav desktop-nav"> <nav class="site-nav desktop-nav">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a> <a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a> <a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a> <a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a> <a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a> <a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a> <a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-layer-group"></span><span>Vorlagen</span></span></a>
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a> <a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
</nav> </nav>
@@ -42,7 +44,7 @@
<small>{{ role_labels[g.user.role] }}</small> <small>{{ role_labels[g.user.role] }}</small>
</a> </a>
{% if g.user.role == 'admin' %} {% if g.user.role == 'admin' %}
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer verwalten</a> <a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('auth.logout') }}"> <form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }} {{ csrf_input() }}
@@ -50,9 +52,9 @@
</form> </form>
</div> </div>
<a class="mobile-profile-link" href="{{ url_for('main.more_view') }}" aria-label="Mehr"> <button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username)[0]|upper }}</span> <span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username or 'N')[:1]|upper }}</span>
</a> </button>
{% endif %} {% endif %}
</header> </header>
@@ -72,6 +74,36 @@
</div> </div>
{% if g.user %} {% if g.user %}
<div class="mobile-sheet-backdrop" data-mobile-sheet-backdrop hidden></div>
<aside class="mobile-more-sheet" data-mobile-sheet hidden aria-label="Mehr">
<div class="mobile-sheet-head">
<div>
<strong>{{ g.user.display_name or g.user.username }}</strong>
<small>{{ role_labels[g.user.role] }}</small>
</div>
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
</div>
<nav class="mobile-sheet-links">
<a href="{{ url_for('main.item_list', kind='food') }}">Lebensmittel</a>
<a href="{{ url_for('main.item_list', kind='meal') }}">Mahlzeiten</a>
<a href="{{ url_for('main.home_view') }}">Zuhause</a>
<a href="{{ url_for('main.archive_view') }}">Archiv</a>
<a href="{{ url_for('main.template_library') }}">Vorlagen</a>
<a href="{{ url_for('auth.profile') }}">Profil</a>
{% if g.user.role == 'admin' %}
<a href="{{ url_for('admin.user_list') }}">Nutzerverwaltung</a>
<a href="{{ url_for('admin.category_settings') }}">Kategorien</a>
{% endif %}
</nav>
<div class="mobile-sheet-actions">
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</aside>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation"> <nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"> <a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span> <span class="ui-icon icon-sparkles"></span>
@@ -85,14 +117,14 @@
<span class="ui-icon icon-calendar"></span> <span class="ui-icon icon-calendar"></span>
<span>Plan</span> <span>Plan</span>
</a> </a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"> <a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-house"></span> <span class="ui-icon icon-calendar-days"></span>
<span>Zuhause</span> <span>Woche</span>
</a> </a>
<a href="{{ url_for('main.more_view') }}" class="{{ 'active' if request.endpoint == 'main.more_view' or request.endpoint == 'auth.profile' or (request.endpoint or '').startswith('admin.') else '' }}"> <button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-archive"></span> <span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span> <span>Mehr</span>
</a> </button>
</nav> </nav>
{% endif %} {% endif %}
</body> </body>
+63 -22
View File
@@ -5,11 +5,11 @@
<div> <div>
<p class="eyebrow">Heute</p> <p class="eyebrow">Heute</p>
<h1>Ein ruhiger Blick auf euren Alltag</h1> <h1>Ein ruhiger Blick auf euren Alltag</h1>
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde und was gemeinsam oder persönlich vorbereitet ist.</p> <p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo sanfte Unterstützung hilfreich sein kann.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a> <a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a> <a class="button secondary" href="{{ url_for('main.template_library') }}">Vorlagen öffnen</a>
</div> </div>
</section> </section>
@@ -31,6 +31,19 @@
</article> </article>
</section> </section>
{% if dashboard_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in dashboard_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="two-column"> <section class="two-column">
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
@@ -47,6 +60,7 @@
<div class="chip-row"> <div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span> <span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span> <span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div> </div>
</div> </div>
{% if entry.availability_state == 'home' %} {% if entry.availability_state == 'home' %}
@@ -72,7 +86,7 @@
<div class="mini-card-body"> <div class="mini-card-body">
<strong>{{ item.name }}</strong> <strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small> <small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
<small>{{ item.owner_label }}</small> <small>{{ item.for_label }}</small>
{% if item.dayparts %} {% if item.dayparts %}
<div class="chip-row"> <div class="chip-row">
{% for daypart in item.dayparts %} {% for daypart in item.dayparts %}
@@ -90,24 +104,51 @@
</article> </article>
</section> </section>
<section class="panel"> <section class="two-column">
<div class="panel-head"> <article class="panel">
<h2>Nächste Tage</h2> <div class="panel-head">
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a> <h2>Vorlagen für später</h2>
</div> <a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
<div class="week-mini-grid"> </div>
{% for card in week_cards %} {% if day_templates or week_templates %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}"> <div class="stack-sections">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong> {% for template in day_templates %}
{% if card.filled_dayparts %} <a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
<span>{{ card.planned_count }} Einträge</span> <strong>{{ template.name }}</strong>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small> <small>Tagesvorlage · {{ template.visibility_label }}</small>
{% else %} </a>
<span>Noch frei</span> {% endfor %}
<small>sanfter Einstieg für den Tag</small> {% for template in week_templates %}
{% endif %} <a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
</a> <strong>{{ template.name }}</strong>
{% endfor %} <small>Wochenvorlage · {{ template.visibility_label }}</small>
</div> </a>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Nächste Tage</h2>
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
</div>
<div class="week-mini-grid">
{% for card in week_cards %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
{% if card.filled_dayparts %}
<span>{{ card.planned_count }} Einträge</span>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %}
<span>Noch frei</span>
<small>sanfter Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
</div>
</article>
</section> </section>
{% endblock %} {% endblock %}
+1
View File
@@ -62,6 +62,7 @@
<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>
<span class="chip">{{ item.for_label }}</span>
</div> </div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p> <p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.components %} {% if item.components %}
+41 -21
View File
@@ -5,12 +5,13 @@
<div> <div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p> <p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1> <h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p> <p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
</div> </div>
{% if item %} {% if item %}
<div class="intro-pills"> <div class="intro-pills">
<span class="status-pill">{{ item.visibility_label }}</span> <span class="status-pill">{{ item.visibility_label }}</span>
<span class="status-pill status-soft">{{ item.owner_label }}</span> <span class="status-pill status-soft">{{ item.owner_label }}</span>
<span class="status-pill">{{ item.for_label }}</span>
</div> </div>
{% endif %} {% endif %}
</section> </section>
@@ -23,15 +24,26 @@
<input type="text" name="name" value="{{ form_data.name }}" required> <input type="text" name="name" value="{{ form_data.name }}" required>
</label> </label>
<label> <div class="dual-grid">
Sichtbarkeit <label>
<select name="visibility"> Sichtbarkeit
{% for value, label in visibility_options %} <select name="visibility">
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option> {% for value, label in visibility_options %}
{% endfor %} <option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
</select> {% endfor %}
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small> </select>
</label> <small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<label>
Für wen?
<select name="target_user_id">
{% for option in target_user_options %}
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</div>
<label> <label>
Kategorie Kategorie
@@ -40,9 +52,6 @@
{% for category in categories %} {% for category in categories %}
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option> <option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %} {% endfor %}
{% if form_data.category and form_data.category not in categories %}
<option value="{{ form_data.category }}" selected>{{ form_data.category }}</option>
{% endif %}
</select> </select>
</label> </label>
@@ -77,20 +86,34 @@
{% if kind == 'meal' %} {% if kind == 'meal' %}
<fieldset> <fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend> <legend>Bestandteile der Mahlzeitenidee</legend>
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenklicken.</p> <p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
<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"
>
</label>
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
</div>
{% if food_groups %} {% if food_groups %}
<div class="stack-sections"> <div class="stack-sections" id="meal-components-list">
{% for group in food_groups %} {% for group in food_groups %}
<div class="component-group"> <div class="component-group">
<div class="panel-head"> <div class="panel-head">
<h3>{{ group["title"] }}</h3> <h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span> <span>{{ group["items"]|length }} Einträge</span>
</div> </div>
<div class="checkbox-grid"> <div class="checkbox-grid filterable-checkbox-group" data-filter-group>
{% for food in group["items"] %} {% for food in group["items"] %}
<label class="check-option"> <label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}> <input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
<span>{{ food.name }} · {{ food.visibility_label }}</span> <span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
@@ -117,9 +140,6 @@
{% for category in categories %} {% for category in categories %}
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option> <option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %} {% endfor %}
{% if form_data.quick_food_category and form_data.quick_food_category not in categories %}
<option value="{{ form_data.quick_food_category }}" selected>{{ form_data.quick_food_category }}</option>
{% endif %}
</select> </select>
</label> </label>
<label class="wide"> <label class="wide">
+2 -1
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p> <p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{{ item_kind_labels[kind] }}</h1> <h1>{{ item_kind_labels[kind] }}</h1>
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert und schnell wiederverwendbar.</p> <p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
</div> </div>
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a> <a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
</section> </section>
@@ -67,6 +67,7 @@
<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>
<span class="chip">{{ item.for_label }}</span>
</div> </div>
<p class="muted"> <p class="muted">
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %} {% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{% if template %}Tagesvorlage bearbeiten{% else %}Neue Tagesvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Tagesvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Tagesvorlage anlegen{% endif %}</h1>
<p class="lead">Gib der Vorlage einen Namen, den du später schnell wiedererkennst. Die Einträge bleiben bewusst einfach und alltagsnah.</p>
</div>
{% if source_date %}
<span class="status-pill">Aus {{ source_date.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Zum Beispiel: Ruhiger Tag, Einfacher Bürotag oder ein ganz eigener Name.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for section in daypart_sections %}
<fieldset>
<legend>{{ section.daypart.name }}</legend>
<div class="template-search-row">
<label class="wide">
Einträge filtern
<input
type="text"
placeholder="Nach Namen suchen"
data-filter-input
data-filter-target="#day-template-list-{{ section.daypart.id }}"
>
</label>
</div>
{% if section.quick_items %}
<div class="quick-add-row">
{% for item in section.quick_items %}
<label class="quick-select-card" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>
<strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</small>
</span>
</label>
{% endfor %}
</div>
{% endif %}
<div class="checkbox-grid template-checkbox-grid" id="day-template-list-{{ section.daypart.id }}">
{% for item in section.list_items %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}Vorlagen | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Vorlagen</p>
<h1>Bewährtes ruhig wiederverwenden</h1>
<p class="lead">Tagesvorlagen, Wochenvorlagen und kleine Pakete helfen dabei, vertraute Muster mit wenig Tipparbeit erneut zu nutzen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('main.day_template_create') }}">Neue Tagesvorlage</a>
<a class="button secondary" href="{{ url_for('main.week_template_create') }}">Neue Wochenvorlage</a>
<a class="button secondary" href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
</section>
<section class="panel compact-form-panel">
<form method="get" class="filter-form">
<label class="wide">
Suche
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if template_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in template_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="template-library-grid">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create') }}">Neu anlegen</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
{% if template.last_used_at %}
<span class="chip">Zuletzt genutzt</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ today.isoformat() }}">
<button type="submit">Heute anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine passende Tagesvorlage. Du kannst eine Vorlage direkt neu anlegen oder aus einem Tagesplan speichern.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create') }}">Neu anlegen</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ today.isoformat() }}">
<button type="submit">Diese Woche anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine Wochenvorlage. Eine gute Woche lässt sich später hier ganz leicht wiederverwenden.</p>
{% endif %}
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Kleine Pakete</h2>
<a href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
{% if item_sets %}
<div class="stack-sections">
{% for item_set in item_sets %}
<article class="template-list-card">
<div>
<strong>{{ item_set.name }}</strong>
{% if item_set.description %}
<p class="muted">{{ item_set.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ item_set.visibility_label }}</span>
<span class="chip status-soft">{{ item_set.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.item_set_apply', set_id=item_set.id) }}">
{{ csrf_input() }}
<button type="submit">Auf Einkaufsliste</button>
</form>
{% if item_set.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_set_edit', set_id=item_set.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Pakete eignen sich gut für kleine Bündel wie schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
{% endif %}
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}{% if item_set %}Paket bearbeiten{% else %}Neues Paket{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kleines Paket</p>
<h1>{% if item_set %}{{ item_set.name }} bearbeiten{% else %}Paket anlegen{% endif %}</h1>
<p class="lead">Pakete bündeln wiederkehrende Dinge ganz leicht, zum Beispiel schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
</div>
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name des Pakets
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<fieldset>
<legend>Einträge auswählen</legend>
<label>
Einträge filtern
<input type="text" placeholder="Nach Namen suchen" data-filter-input data-filter-target="#item-set-list">
</label>
<div class="stack-sections" id="item-set-list">
{% for group in item_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">
{% for item in group["items"] %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{% if template %}Wochenvorlage bearbeiten{% else %}Neue Wochenvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Wochenvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Wochenvorlage anlegen{% endif %}</h1>
<p class="lead">Wochenvorlagen bleiben bewusst leicht: pro Wochentag kannst du eine bestehende Tagesvorlage zuordnen oder einen aktuellen Tag als neue Vorlage übernehmen.</p>
</div>
{% if source_week %}
<span class="status-pill">Aus Woche ab {{ source_week.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="source_week" value="{{ form_data.source_week }}">
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Ein Name wie Standardwoche, leichte Woche oder etwas ganz Eigenes reicht völlig aus.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for weekday_index in range(7) %}
<div class="week-template-row">
<div>
<strong>{{ weekday_labels[weekday_index] }}</strong>
<p class="muted">Du kannst eine vorhandene Tagesvorlage auswählen oder den aktuellen Tag aus der Quellwoche übernehmen.</p>
</div>
<label>
Tagesvorlage
<select name="weekday_{{ weekday_index }}_day_template_id">
<option value="">Noch offen</option>
{% for day_template in day_templates %}
<option value="{{ day_template.id }}" {% if form_data.selected_map.get(weekday_index) == day_template.id %}selected{% endif %}>{{ day_template.name }} · {{ day_template.visibility_label }}</option>
{% endfor %}
</select>
</label>
{% if form_data.source_week %}
<label class="inline-check">
<input type="checkbox" name="weekday_{{ weekday_index }}_copy_source" value="1" {% if form_data.copy_from_source.get(weekday_index) %}checked{% endif %}>
<span>Aus Quellwoche als neue Tagesvorlage übernehmen</span>
</label>
{% endif %}
</div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
-56
View File
@@ -1,56 +0,0 @@
{% extends "base.html" %}
{% block title %}Mehr | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Mehr</p>
<h1>Alles Weitere auf einen Blick</h1>
<p class="lead">Hier liegen die ruhigeren Nebenwege: Lebensmittel, Mahlzeiten, Woche, Profil und die kleinen Einstellungen.</p>
</div>
<div class="intro-pills">
<span class="status-pill">{{ g.user.display_name or g.user.username }}</span>
<span class="status-pill status-soft">{{ role_labels[g.user.role] }}</span>
</div>
</section>
<section class="panel">
<div class="more-link-grid">
<a class="more-link-card" href="{{ url_for('main.item_list', kind='food') }}">
<strong>Lebensmittel</strong>
<small>Persönliche und gemeinsame Einträge ansehen</small>
</a>
<a class="more-link-card" href="{{ url_for('main.item_list', kind='meal') }}">
<strong>Mahlzeiten</strong>
<small>Mahlzeitenideen sammeln und wiederverwenden</small>
</a>
<a class="more-link-card" href="{{ url_for('main.planner') }}">
<strong>Woche</strong>
<small>Die nächsten sieben Tage im Blick behalten</small>
</a>
<a class="more-link-card" href="{{ url_for('main.archive_view') }}">
<strong>Archiv</strong>
<small>Vertraute Dinge leicht wiederfinden</small>
</a>
<a class="more-link-card" href="{{ url_for('auth.profile') }}">
<strong>Mein Profil</strong>
<small>Zugang, Name und Passwort pflegen</small>
</a>
{% if g.user.role == 'admin' %}
<a class="more-link-card" href="{{ url_for('admin.user_list') }}">
<strong>Nutzer verwalten</strong>
<small>Weitere Haushaltsmitglieder verwalten</small>
</a>
{% endif %}
</div>
</section>
<section class="panel">
<div class="more-actions">
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</section>
{% endblock %}
+54 -2
View File
@@ -14,6 +14,45 @@
</div> </div>
</section> </section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
{% endif %}
</article>
{% if day_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in day_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
<section class="planner-day-stack"> <section class="planner-day-stack">
{% for section in sections %} {% for section in sections %}
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}> <details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
@@ -33,6 +72,18 @@
</summary> </summary>
<div class="day-tile-body"> <div class="day-tile-body">
{% if section.suggestions %}
<div class="suggestion-row">
{% for suggestion in section.suggestions %}
<article class="suggestion-card">
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
<p>{{ suggestion.note }}</p>
</article>
{% endfor %}
</div>
{% endif %}
{% if section.quick_items %} {% if section.quick_items %}
<div class="quick-add-row"> <div class="quick-add-row">
{% for item in section.quick_items %} {% for item in section.quick_items %}
@@ -44,7 +95,7 @@
<input type="hidden" name="visibility" value="{{ item.visibility }}"> <input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button" type="submit"> <button class="quick-add-button" type="submit">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small> <small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
@@ -61,7 +112,7 @@
<option value="">Etwas für {{ section.daypart.name }} wählen</option> <option value="">Etwas für {{ section.daypart.name }} wählen</option>
{% for item in section.candidates %} {% for item in section.candidates %}
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}> <option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %} {{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -92,6 +143,7 @@
<div class="chip-row"> <div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span> <span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span> <span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div> </div>
</div> </div>
{% if entry.can_edit %} {% if entry.can_edit %}
+41 -2
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Wochenansicht</p> <p class="eyebrow">Wochenansicht</p>
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1> <h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p> <p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und eine Woche bei Bedarf für später sichern.</p>
</div> </div>
<div class="week-nav"> <div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
@@ -14,6 +14,45 @@
</div> </div>
</section> </section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create', source_week=week_start.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ week_start.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn eine Woche sich bewährt hat, kannst du sie hier später als Wochenvorlage wiederverwenden.</p>
{% endif %}
</article>
{% if week_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in week_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}"> <section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
{% for card in week_cards %} {% for card in week_cards %}
<article class="week-card"> <article class="week-card">
@@ -51,7 +90,7 @@
{% for entry in slot.entries %} {% for entry in slot.entries %}
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}"> <article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<small>{{ entry.visibility_label }} · {{ entry.owner_label }}</small> <small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+9 -2
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Einkaufsliste</p> <p class="eyebrow">Einkaufsliste</p>
<h1>Was noch mitkommen soll</h1> <h1>Was noch mitkommen soll</h1>
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. Gemeinsame und persönliche Einträge bleiben dabei klar erkennbar.</p> <p class="lead">Fehlende Lebensmittel aus einer Mahlzeit landen jetzt einzeln hier. So bleibt die Liste konkret und alltagsnah.</p>
</div> </div>
</section> </section>
@@ -35,6 +35,13 @@
<div class="chip-row"> <div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span> <span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span> <span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
{% if entry.needed_for_label %}
<span class="chip status-home">
Für {{ entry.needed_for_label }}
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
</span>
{% endif %}
</div> </div>
</div> </div>
<div class="row-actions"> <div class="row-actions">
@@ -55,7 +62,7 @@
{% else %} {% else %}
<section class="panel empty-panel"> <section class="panel empty-panel">
<h2>Die Liste ist gerade frei</h2> <h2>Die Liste ist gerade frei</h2>
<p>Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.</p> <p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}