4 Commits

12 changed files with 302 additions and 110 deletions
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
data/
instance/
.cloudron-push.env
.env.local
.env.push.local
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "0.5.0",
"upstreamVersion": "0.5.0",
"version": "0.5.1",
"upstreamVersion": "0.5.1",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
+2 -1
View File
@@ -11,7 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -m -d /home/cloudron cloudron
RUN groupadd --gid 1000 cloudron \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
COPY requirements.txt /app/code/
RUN pip install --no-cache-dir -r requirements.txt gunicorn
+41
View File
@@ -0,0 +1,41 @@
# Push-Setup für Nouri
## 1. VAPID-Schlüssel erzeugen
```bash
. .venv/bin/activate
python scripts/generate_vapid_keys.py
```
Das Script gibt drei Zeilen aus:
- `NOURI_VAPID_PUBLIC_KEY`
- `NOURI_VAPID_PRIVATE_KEY`
- `NOURI_VAPID_SUBJECT`
## 2. In Cloudron eintragen
In der bestehenden Nouri-App unter `Settings``Environment Variables` diese drei Werte anlegen:
```text
NOURI_VAPID_PUBLIC_KEY=...
NOURI_VAPID_PRIVATE_KEY=...
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
```
Danach die App neu starten.
## 3. Auf dem iPhone aktivieren
1. `nouri.heinz.media` in Safari öffnen
2. `Teilen``Zum Home-Bildschirm`
3. die installierte Web-App öffnen
4. in Nouri `Optionen` öffnen
5. `Push erlauben` tippen
6. danach optional `Test-Mitteilung senden`
## 4. Bereits vorbereitete lokale Datei
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
+32
View File
@@ -0,0 +1,32 @@
# Nouri 0.5.1
## Highlights
- Smartphone-Navigation unten neu als echte Erweiterung umgesetzt
- obere Nouri-Leiste auf kleinen Geräten nicht mehr sticky, sondern sauber fest positioniert
- PWA-Cache für frische Layout- und Einstellungsänderungen bereinigt
- Cloudron-Version auf `0.5.1` angehoben
## Neu in 0.5.1
### Mobile Navigation
- `Mehr` ist auf Smartphones kein schwebendes Overlay mehr.
- Die zusätzlichen Punkte klappen jetzt direkt aus der unteren Navigation heraus auf.
- Die Zusatzpunkte nutzen dieselbe kompakte Größe wie die unteren Menüpunkte.
- Der untere Navigationsbereich wird dabei nicht weichgezeichnet.
### Mobile Header
- Die obere Nouri-Leiste scrollt auf kleinen Geräten nicht mehr mit dem Inhalt.
- Die bisherige `sticky`-Logik für den Header wurde entfernt, damit es keine widersprüchlichen Zustände mehr gibt.
### PWA
- Der Service Worker verwendet einen aktualisierten Cache-Namen.
- Navigationsseiten werden frischer geladen, damit Änderungen an Einstellungen und Layout nicht an altem Cache hängen bleiben.
## Cloudron
- `CloudronManifest.json` wurde auf `0.5.1` angehoben.
- Damit lässt sich das Update sauber als neue App-Version ausrollen.
+1 -1
View File
@@ -68,7 +68,7 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
APP_VERSION="0.5.0",
APP_VERSION="0.5.1",
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
+95 -30
View File
@@ -152,8 +152,7 @@ button.secondary:hover,
}
.site-header {
position: sticky;
top: 1rem;
position: static;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr auto;
@@ -969,6 +968,16 @@ legend {
color: var(--accent-strong);
}
.menu-card-button {
width: 100%;
cursor: pointer;
font: inherit;
}
.menu-card-form {
margin: 0;
}
.roomy-row {
padding: 1rem 1.2rem;
}
@@ -1191,6 +1200,10 @@ legend {
backdrop-filter: blur(6px);
}
.mobile-nav-stack {
display: none;
}
.mobile-more-sheet {
position: fixed;
left: 0.75rem;
@@ -1223,6 +1236,10 @@ legend {
margin: 1rem 0;
}
.mobile-menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mobile-sheet-actions {
flex-wrap: wrap;
}
@@ -1241,9 +1258,19 @@ legend {
align-items: flex-start;
}
body.has-mobile-nav {
padding-top: 5.3rem;
}
.site-header {
position: static;
grid-template-columns: 1fr;
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 0.5rem);
left: 0.5rem;
right: 0.5rem;
grid-template-columns: 1fr auto;
z-index: 30;
width: auto;
margin: 0;
}
.stats-grid,
@@ -1273,13 +1300,12 @@ legend {
}
.site-header {
position: sticky;
top: 0.7rem;
grid-template-columns: 1fr auto;
gap: 0.6rem;
padding: 0.75rem 0.9rem;
margin-bottom: 0.85rem;
margin-bottom: 0;
border-radius: 22px;
z-index: 30;
}
.desktop-nav,
@@ -1377,54 +1403,95 @@ legend {
min-width: 100%;
}
.mobile-bottom-nav {
.mobile-nav-stack {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: 0.75rem;
z-index: 20;
z-index: 24;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.5rem;
border-radius: 22px;
background: var(--bg-elevated);
background: color-mix(in srgb, var(--bg) 96%, #f6decb 4%);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(26px) saturate(1.15);
}
.mobile-bottom-nav a {
.mobile-nav-extension {
display: none;
}
.mobile-nav-stack.is-open .mobile-nav-extension {
display: grid;
justify-items: center;
gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px;
color: var(--muted);
font-size: 0.78rem;
}
.mobile-nav-extension,
.mobile-sheet-links.mobile-menu-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.35rem;
margin: 0;
}
.mobile-extra-link,
.mobile-extra-button,
.mobile-bottom-nav a,
.mobile-nav-button {
display: grid;
justify-items: center;
align-content: center;
display: grid;
min-height: 3.95rem;
padding: 0.55rem 0.2rem 0.5rem;
text-align: center;
gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px;
border: 0;
background: transparent;
box-shadow: none;
color: var(--muted);
font-size: 0.78rem;
border: 0;
}
.mobile-bottom-nav a.active,
.mobile-nav-button.is-open {
background: var(--accent-soft);
color: var(--text);
}
.mobile-extra-link .ui-icon,
.mobile-extra-button .ui-icon,
.mobile-bottom-nav .ui-icon {
width: 1rem;
height: 1rem;
margin-top: 0;
}
.mobile-extra-link span:last-child,
.mobile-extra-button span:last-child,
.mobile-bottom-nav a span:last-child,
.mobile-nav-button span:last-child {
font-size: 0.72rem;
line-height: 1.08;
}
.mobile-extra-form {
display: contents;
}
.mobile-bottom-nav {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
padding: 0;
}
.mobile-bottom-nav a {
}
.mobile-nav-button {
cursor: pointer;
font: inherit;
}
.mobile-bottom-nav a.active,
.mobile-extra-link.active,
.mobile-nav-button.is-open {
background: var(--accent-soft);
color: var(--text);
}
.mobile-profile-link {
@@ -1438,8 +1505,6 @@ legend {
height: 2.15rem;
}
.mobile-sheet-head,
.mobile-sheet-actions,
.week-template-row {
align-items: flex-start;
}
+16 -12
View File
@@ -1,32 +1,33 @@
(() => {
const initMobileSheet = () => {
const sheet = document.querySelector("[data-mobile-sheet]");
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
const navStack = document.querySelector("[data-mobile-nav-stack]");
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
if (!sheet || !backdrop || !openButtons.length) return;
if (!sheet || !navStack || !openButtons.length) return;
const closeSheet = () => {
sheet.hidden = true;
backdrop.hidden = true;
document.body.classList.remove("sheet-open");
navStack.classList.remove("is-open");
openButtons.forEach((button) => button.classList.remove("is-open"));
};
const openSheet = () => {
sheet.hidden = false;
backdrop.hidden = false;
document.body.classList.add("sheet-open");
navStack.classList.add("is-open");
openButtons.forEach((button) => button.classList.add("is-open"));
};
const toggleSheet = () => {
if (sheet.hidden) {
openSheet();
} else {
closeSheet();
}
};
openButtons.forEach((button) => {
button.addEventListener("click", openSheet);
button.addEventListener("click", toggleSheet);
});
closeButtons.forEach((button) => {
button.addEventListener("click", closeSheet);
});
backdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
@@ -37,6 +38,9 @@
sheet.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeSheet);
});
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", closeSheet);
});
};
const initFilterInputs = () => {
+27 -5
View File
@@ -1,6 +1,5 @@
const CACHE_NAME = "nouri-v0-5-0";
const APP_SHELL = [
"/",
const CACHE_NAME = "nouri-v0-5-1-1";
const STATIC_ASSETS = [
"/static/css/styles.css",
"/static/js/theme.js",
"/static/js/ui.js",
@@ -13,7 +12,7 @@ const APP_SHELL = [
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting())
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
);
});
@@ -27,9 +26,32 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const requestUrl = new URL(event.request.url);
const isStaticAsset =
requestUrl.origin === self.location.origin &&
(
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (!isStaticAsset) {
return;
}
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
+44 -48
View File
@@ -93,59 +93,55 @@
</div>
{% 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 card-link-grid">
<a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
<a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
<div class="mobile-nav-stack" data-mobile-nav-stack>
<nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
<a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
{% if g.user.role == 'admin' %}
<a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></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') }}">
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle>
<span class="ui-icon icon-mobile-screen-button"></span>
<span>Modus</span>
</button>
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
<button class="mobile-extra-link mobile-extra-button" type="submit">
<span class="ui-icon icon-ellipsis"></span>
<span>Abmelden</span>
</button>
</form>
</div>
</aside>
</nav>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span>
<span>Heute</span>
</a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
<span class="ui-icon icon-cart-shopping"></span>
<span>Einkauf</span>
</a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
<span class="ui-icon icon-calendar"></span>
<span>Plan</span>
</a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-calendar-days"></span>
<span>Woche</span>
</a>
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span>
</button>
</nav>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span>
<span>Heute</span>
</a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
<span class="ui-icon icon-cart-shopping"></span>
<span>Einkauf</span>
</a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
<span class="ui-icon icon-calendar"></span>
<span>Plan</span>
</a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-calendar-days"></span>
<span>Woche</span>
</a>
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span>
</button>
</nav>
</div>
{% endif %}
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from py_vapid import Vapid01, b64urlencode
from cryptography.hazmat.primitives import serialization
def main() -> None:
vapid = Vapid01()
vapid.generate_keys()
public_key = b64urlencode(
vapid.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
private_value = vapid.private_key.private_numbers().private_value
private_key = b64urlencode(private_value.to_bytes(32, "big"))
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
if __name__ == "__main__":
main()
+13 -11
View File
@@ -1,14 +1,16 @@
#!/bin/sh
#!/bin/bash
set -eu
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
mkdir -p "${NOURI_DATA_DIR}"
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
mkdir -p "${NOURI_DATA_DIR}/uploads"
mkdir -p /app/data/uploads
exec gunicorn \
--bind 0.0.0.0:8000 \
--workers 2 \
--threads 4 \
--timeout 60 \
wsgi:app
# Vorhandene lokale SQLite-Datei beim allerersten Start übernehmen
if [ ! -f /app/data/nouri.sqlite3 ] && [ -f /app/bootstrap-data/nouri.sqlite3 ]; then
cp /app/bootstrap-data/nouri.sqlite3 /app/data/nouri.sqlite3
fi
# Vorhandene Uploads beim allerersten Start übernehmen
if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/null || true)" ]; then
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
fi
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app