diff --git a/CloudronManifest.json b/CloudronManifest.json index 63a35f3..070d68e 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "1.2.2", - "upstreamVersion": "1.2.2", + "version": "1.3.0", + "upstreamVersion": "1.3.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_1.3.0.md b/RELEASE_NOTES_1.3.0.md new file mode 100644 index 0000000..8e2a639 --- /dev/null +++ b/RELEASE_NOTES_1.3.0.md @@ -0,0 +1,50 @@ +# Nouri 1.3.0 + +Nouri 1.3.0 ist ein größerer Alltags- und Pflege-Release auf Basis von 1.2.2. Der Schwerpunkt liegt auf klareren Lebensmittel-Zuständen, einer ruhigeren Einkaufsliste und Formularen, die beim Bearbeiten nicht mehr aus dem Arbeitsfluss reißen. + +## Neu in 1.3.0 + +- Lebensmittel haben jetzt eine klarere Alltagslogik: + - `Zuhause` ist ein Bestandsstatus + - `Gerade nicht da` ist ein fehlender Bestand + - `Archiviert` bleibt eine bewusste Entscheidung + - `Unsortiert` ist ein neuer Zwischenstatus für schnelle Sammelerfassung +- Über `Schnell anlegen` lassen sich mehrere Lebensmittel auf einmal erfassen. + - Die Einträge landen zunächst in `Unsortiert` + - sie tauchen erst nach späterer Einordnung regulär im Alltag auf +- Die Lebensmittelkarten wurden deutlich vereinfacht: + - Bild oder passendes Icon + - Titel + - Bearbeitung über die ganze Kachel + - Archivieren über ein kleines `x` + - Zusatzinfos nur noch als ruhige Hover-Ebene +- Die Einkaufsliste wurde klarer und direkter: + - Suche nach Lebensmitteln statt Dropdown + - Einträge mit Bild oder passendem Icon + - Bearbeitung über Popup + - Archivierte und unsortierte Lebensmittel können ebenfalls über die Suche wieder auf die Einkaufsliste gesetzt werden + +## Enthaltene Feinschliffe seit 1.2.2 + +- Die Lebensmittel- und Mahlzeitenlogik wurde weiter geschärft: + - Geschmacksrichtung `süß`, `herzhaft`, `neutral` + - bessere Filterung kulinarisch passender Vorschläge + - klarere Builder-Begriffe und ruhigere Formulare +- Auswahlbereiche für Tageszeiten, Mahlzeit-Charakter und Builder-Felder wurden weiter vereinheitlicht. +- Ausgewählte Zutaten in Mahlzeiten und Paketen sind sichtbarer und leichter bearbeitbar. +- Mehr Formulare unterstützen jetzt einen ruhigeren Bearbeitungsfluss: + - `Speichern` + - `Speichern und schließen` + - Scroll-Position bleibt beim Weiterbearbeiten erhalten +- Die Navigation und mehrere Detailansichten wurden weiter beruhigt und konsistenter gemacht. + +## Technisch + +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.0`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.0` angehoben. +- Die Datenbank kennt jetzt zusätzliche Zustände und Felder für die überarbeitete Lebensmittel-Logik, unter anderem für `Unsortiert`. + +## Hinweis zum Update + +- Beim Cloudron-Update sollte der `data`-Ordner weiterhin schreibbar sein, damit Schema-Änderungen sauber angewendet werden können. +- Für produktive Updates bleibt ein reguläres Cloudron-Backup vor dem Rollout die sichere Variante. diff --git a/nouri/__init__.py b/nouri/__init__.py index b610bfd..69143f6 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str: ).strip() if manifest_version: return manifest_version - return "1.2.2" + return "1.3.0" def load_release_url() -> str: diff --git a/nouri/admin.py b/nouri/admin.py index c820192..4ec088a 100644 --- a/nouri/admin.py +++ b/nouri/admin.py @@ -3,7 +3,7 @@ from __future__ import annotations from flask import Blueprint, flash, g, redirect, render_template, request, url_for 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, url_with_scroll_position, validate_admin_user_form, wants_to_stay_on_form from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS from .db import get_db @@ -104,6 +104,19 @@ def user_create(): ) database.commit() flash("Der Nutzer wurde angelegt.", "success") + if wants_to_stay_on_form(): + new_user = database.execute( + """ + SELECT id + FROM users + WHERE household_id = ? AND username = ? + ORDER BY id DESC + LIMIT 1 + """, + (g.user["household_id"], form_data["username"]), + ).fetchone() + if new_user is not None: + return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=int(new_user["id"])))) return redirect(url_for("admin.user_list")) flash(error, "error") @@ -185,6 +198,8 @@ def user_edit(user_id: int): ) database.commit() flash("Der Nutzer wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=user_id))) return redirect(url_for("admin.user_list")) flash(error, "error") diff --git a/nouri/auth.py b/nouri/auth.py index 52ff0b0..c2c54c6 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations import functools import secrets +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from flask import ( Blueprint, @@ -25,23 +26,23 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth") def login_required(view): @functools.wraps(view) - def wrapped_view(**kwargs): + def wrapped_view(*args, **kwargs): if g.user is None: return redirect(url_for("auth.login")) - return view(**kwargs) + return view(*args, **kwargs) return wrapped_view def admin_required(view): @functools.wraps(view) - def wrapped_view(**kwargs): + def wrapped_view(*args, **kwargs): if g.user is None: return redirect(url_for("auth.login")) if g.user["role"] != "admin": flash("Dieser Bereich ist für Admins gedacht.", "error") return redirect(url_for("main.dashboard")) - return view(**kwargs) + return view(*args, **kwargs) return wrapped_view @@ -53,6 +54,25 @@ def ensure_csrf_token() -> str: return token +def wants_to_stay_on_form() -> bool: + return request.form.get("save_mode", "").strip() == "stay" + + +def url_with_scroll_position(url: str) -> str: + raw_scroll = request.form.get("_scroll", "").strip() + if not raw_scroll: + return url + try: + scroll_value = max(0, int(float(raw_scroll))) + except ValueError: + return url + + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["_scroll"] = str(scroll_value) + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + def normalize_login_value(raw: str) -> str: return raw.strip().lower() @@ -231,7 +251,9 @@ def profile(): ) database.commit() flash("Dein Profil wurde aktualisiert.", "success") - return redirect(url_for("auth.profile")) + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("auth.profile"))) + return redirect(url_for("main.dashboard")) flash(error, "error") diff --git a/nouri/constants.py b/nouri/constants.py index 5034411..cf5f8c3 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -17,6 +17,7 @@ DAYPART_SLUG_TO_MEAL_TYPE = { } DEFAULT_CATEGORIES = [ + "Unsortiert", "Kohlenhydrate", "Milchprodukt", "Obst", @@ -30,6 +31,7 @@ DEFAULT_CATEGORIES = [ ] DEFAULT_CATEGORY_BUILDERS = { + "Unsortiert": "neutral", "Kohlenhydrate": "carb", "Brot & Getreide": "carb", "Milchprodukt": "dairy", @@ -226,9 +228,10 @@ ITEM_KIND_SINGULAR_LABELS = { } AVAILABILITY_LABELS = { - "idea": "Merkliste", + "idea": "Gerade nicht da", "home": "Zuhause", - "archived": "Archiv", + "unsorted": "Unsortiert", + "archived": "Archiviert", } ROLE_LABELS = { diff --git a/nouri/db.py b/nouri/db.py index 33a4cf9..b127ade 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -15,7 +15,7 @@ from .constants import ( DEFAULT_CATEGORY_BUILDERS, ) -CURRENT_SCHEMA_VERSION = "1.2.2" +CURRENT_SCHEMA_VERSION = "1.3.0" ANIMAL_HINTS = ( "huhn", @@ -344,6 +344,19 @@ def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None: set_meta(database, "food_flavor_profiles_migrated", "1") +def migrate_item_archive_state(database: sqlite3.Connection) -> None: + if get_meta(database, "item_archive_state_migrated") == "1": + return + + if "is_archived" not in table_columns(database, "items"): + return + + database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL") + database.execute("UPDATE items SET is_archived = 1 WHERE availability_state = 'archived'") + database.execute("UPDATE items SET availability_state = 'idea' WHERE availability_state = 'archived'") + set_meta(database, "item_archive_state_migrated", "1") + + def get_db() -> sqlite3.Connection: if "db" not in g: g.db = sqlite3.connect( @@ -468,6 +481,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "meal_type TEXT") add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") 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") if table_exists(database, "shopping_entries"): add_column_if_missing(database, "shopping_entries", "household_id INTEGER") @@ -722,6 +736,8 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "meal_type TEXT") add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0") 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, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'") @@ -771,6 +787,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: sync_default_categories(database) migrate_item_profiles(database) migrate_food_flavor_profiles(database) + migrate_item_archive_state(database) database.execute( """ INSERT OR IGNORE INTO user_settings (user_id) @@ -784,6 +801,8 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''") database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL") database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL") + database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL") + database.execute("UPDATE items SET is_quick_added = 0 WHERE is_quick_added IS NULL") database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''") database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''") database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''") @@ -805,6 +824,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON items (household_id, visibility, availability_state) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived + ON items (household_id, visibility, is_archived, availability_state) + """ + ) database.execute( """ CREATE INDEX IF NOT EXISTS idx_items_target_user diff --git a/nouri/schema.sql b/nouri/schema.sql index f3c72ee..ac14e3e 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -132,6 +132,8 @@ CREATE TABLE IF NOT EXISTS items ( 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, @@ -304,6 +306,9 @@ ON items (kind, name); CREATE INDEX IF NOT EXISTS idx_items_household_visibility ON items (household_id, visibility, availability_state); +CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived +ON items (household_id, visibility, is_archived, availability_state); + CREATE INDEX IF NOT EXISTS idx_items_target_user ON items (target_user_id); diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index 390afeb..c7b58a6 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -1,4 +1,62 @@ (() => { + const scrollStorageKey = () => `nouri-scroll:${window.location.pathname}${window.location.search}`; + + const initPostFormScrollMemory = () => { + const restoreFromUrl = () => { + const currentUrl = new URL(window.location.href); + const rawScroll = currentUrl.searchParams.get("_scroll"); + if (!rawScroll) return false; + + const scrollValue = Number.parseInt(rawScroll, 10); + currentUrl.searchParams.delete("_scroll"); + + window.requestAnimationFrame(() => { + if (Number.isFinite(scrollValue)) { + window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" }); + } + window.history.replaceState({}, "", currentUrl.toString()); + }); + return true; + }; + + const restoreFromStorage = () => { + const savedScroll = sessionStorage.getItem(scrollStorageKey()); + if (!savedScroll) return; + + sessionStorage.removeItem(scrollStorageKey()); + const scrollValue = Number.parseInt(savedScroll, 10); + if (!Number.isFinite(scrollValue)) return; + + window.requestAnimationFrame(() => { + window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" }); + }); + }; + + if (!restoreFromUrl()) { + restoreFromStorage(); + } + + document.addEventListener("submit", (event) => { + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; + + const method = (form.getAttribute("method") || "get").toLowerCase(); + if (method !== "post") return; + + const scrollValue = String(Math.round(window.scrollY)); + sessionStorage.setItem(scrollStorageKey(), scrollValue); + + let scrollInput = form.querySelector('input[name="_scroll"]'); + if (!(scrollInput instanceof HTMLInputElement)) { + scrollInput = document.createElement("input"); + scrollInput.type = "hidden"; + scrollInput.name = "_scroll"; + form.appendChild(scrollInput); + } + scrollInput.value = scrollValue; + }); + }; + const initMobileSheet = () => { const sheet = document.querySelector("[data-mobile-sheet]"); const navStack = document.querySelector("[data-mobile-nav-stack]"); @@ -153,9 +211,66 @@ }, { passive: false }); }; + const initDialogs = () => { + document.addEventListener("click", (event) => { + const openButton = event.target.closest("[data-dialog-open]"); + if (openButton instanceof HTMLElement) { + const dialogId = openButton.getAttribute("data-dialog-open"); + if (!dialogId) return; + const dialog = document.getElementById(dialogId); + if (dialog instanceof HTMLDialogElement) { + dialog.showModal(); + } + return; + } + + const closeButton = event.target.closest("[data-dialog-close]"); + if (closeButton instanceof HTMLElement) { + const dialog = closeButton.closest("dialog"); + if (dialog instanceof HTMLDialogElement) { + dialog.close(); + } + } + }); + + document.addEventListener("keydown", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const openButton = target.closest("[data-dialog-open]"); + if (!(openButton instanceof HTMLElement)) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + const dialogId = openButton.getAttribute("data-dialog-open"); + if (!dialogId) return; + const dialog = document.getElementById(dialogId); + if (dialog instanceof HTMLDialogElement) { + dialog.showModal(); + } + }); + + document.querySelectorAll("dialog").forEach((dialog) => { + dialog.addEventListener("click", (event) => { + if (event.target === dialog && dialog instanceof HTMLDialogElement) { + dialog.close(); + } + }); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + document.querySelectorAll("dialog[open]").forEach((dialog) => { + if (dialog instanceof HTMLDialogElement) { + dialog.close(); + } + }); + }); + }; + document.addEventListener("DOMContentLoaded", () => { + initPostFormScrollMemory(); initMobileSheet(); initFilterInputs(); initIosPullToRefresh(); + initDialogs(); }); })(); diff --git a/nouri/templates/admin/user_form.html b/nouri/templates/admin/user_form.html index e18a2c4..bc64622 100644 --- a/nouri/templates/admin/user_form.html +++ b/nouri/templates/admin/user_form.html @@ -45,7 +45,8 @@
- + + Zurück
diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index c533f10..3eff087 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -5,7 +5,7 @@

Archiv

Frühere Ideen bleiben greifbar

-

Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.

+

Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.

@@ -93,13 +93,15 @@
{{ csrf_input() }} - +
- Im Tagesplan öffnen + {% if item.can_edit %} + Bearbeiten + {% endif %} {% if item.can_edit %}
{{ csrf_input() }} - +
{% endif %}
diff --git a/nouri/templates/auth/profile.html b/nouri/templates/auth/profile.html index fe2856a..7ac8c3c 100644 --- a/nouri/templates/auth/profile.html +++ b/nouri/templates/auth/profile.html @@ -41,7 +41,10 @@ E-Mail - +
+ + +
diff --git a/nouri/templates/dashboard.html b/nouri/templates/dashboard.html index b336567..d20cd98 100644 --- a/nouri/templates/dashboard.html +++ b/nouri/templates/dashboard.html @@ -80,7 +80,7 @@ {{ entry.for_label }} - {% if entry.availability_state == 'home' %} + {% if entry.is_home %} zuhause {% endif %} diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index e9fa758..708475d 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -5,7 +5,7 @@

Zuhause

Was aktuell da ist

-

Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.

+

Hier erscheinen aktive Lebensmittel und Mahlzeitenideen, die gerade wirklich da sind. Wenn etwas leer ist, wird es einfach als gerade nicht da markiert.

@@ -124,17 +124,27 @@ {% endif %}
- Im Tagesplan öffnen - {% if item.can_edit %} -
+ {% if item.kind == 'meal' %} + Im Tagesplan öffnen + {% else %} + {% if item.can_edit %} + + {{ csrf_input() }} + +
+ {% endif %} +
{{ csrf_input() }} - +
+ {% if item.can_edit %} + Bearbeiten +
+ {{ csrf_input() }} + +
+ {% endif %} {% endif %} -
- {{ csrf_input() }} - -
{% endfor %} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index 704adc2..94d21bd 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -351,7 +351,8 @@ {% endif %}
- + + Zurück
diff --git a/nouri/templates/items/quick_add.html b/nouri/templates/items/quick_add.html new file mode 100644 index 0000000..28678d1 --- /dev/null +++ b/nouri/templates/items/quick_add.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}Schnell anlegen | Nouri{% endblock %} +{% block content %} +
+
+

Lebensmittel

+

Schnell anlegen

+

Hier kannst du mehrere Lebensmittel in einem Schritt anlegen. Sie landen zuerst als ruhige Platzhalter auf der Einkaufsliste und lassen sich später einzeln weiter ordnen.

+
+ Zurück +
+ +
+
+ {{ csrf_input() }} + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ Passende Tageszeiten +
+ {% for daypart in dayparts %} + + {% endfor %} +
+
+ + + +
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/nouri/templates/library/day_form.html b/nouri/templates/library/day_form.html index 6ea16b1..f1491cb 100644 --- a/nouri/templates/library/day_form.html +++ b/nouri/templates/library/day_form.html @@ -85,7 +85,8 @@
- + + Zurück
diff --git a/nouri/templates/library/set_form.html b/nouri/templates/library/set_form.html index 4e723c8..3a9d7f5 100644 --- a/nouri/templates/library/set_form.html +++ b/nouri/templates/library/set_form.html @@ -155,7 +155,8 @@
- + + Zurück
diff --git a/nouri/templates/library/week_form.html b/nouri/templates/library/week_form.html index 9ea3f6a..edafc1c 100644 --- a/nouri/templates/library/week_form.html +++ b/nouri/templates/library/week_form.html @@ -71,7 +71,8 @@
- + + Zurück
diff --git a/nouri/templates/planner/day.html b/nouri/templates/planner/day.html index 80de42f..745321f 100644 --- a/nouri/templates/planner/day.html +++ b/nouri/templates/planner/day.html @@ -127,7 +127,7 @@ {% endfor %} @@ -190,7 +190,7 @@ {{ item.name }} {{ item_kind_labels[item.kind] }} - {% if item.availability_state == 'home' %} · zuhause{% endif %} + {% if item.is_home %} · zuhause{% endif %} @@ -205,7 +205,7 @@
{{ entry.item_name }} - {{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %} + {{ item_kind_labels[entry.item_kind] }}{% if entry.is_home %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}
{{ entry.visibility_label }} {{ entry.owner_label }} diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html index 3918462..da872b3 100644 --- a/nouri/templates/planner/week.html +++ b/nouri/templates/planner/week.html @@ -161,7 +161,7 @@ {% endfor %} diff --git a/nouri/templates/settings.html b/nouri/templates/settings.html index ee081d9..a9a83c7 100644 --- a/nouri/templates/settings.html +++ b/nouri/templates/settings.html @@ -34,7 +34,8 @@
- + +
@@ -151,7 +152,8 @@
- + + Zum Profil