release nouri 0.5.0 shopping rhythm pwa and reminders
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -128,6 +128,29 @@ button.secondary:hover,
|
||||
margin: 1rem auto 2rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0.35rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-copy .ui-icon {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
@@ -445,6 +468,7 @@ h3 {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.stacked-mobile {
|
||||
@@ -616,6 +640,7 @@ input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
@@ -862,6 +887,111 @@ legend {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.planner-subsection {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.planner-subsection h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.planner-search {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.compact-picker-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.compact-picker-list form[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.picker-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.compact-quick-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-width: 150px;
|
||||
padding: 0.78rem 0.9rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pwa-card {
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
|
||||
}
|
||||
|
||||
.card-link-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
|
||||
}
|
||||
|
||||
.menu-card .ui-icon {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.roomy-row {
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
|
||||
.inline-form-tight {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.inline-form-tight > :first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.planner-entry-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -1018,6 +1148,41 @@ legend {
|
||||
mask-image: url("../icons/fa/ellipsis.svg");
|
||||
}
|
||||
|
||||
.icon-heart {
|
||||
-webkit-mask-image: url("../icons/fa/heart.svg");
|
||||
mask-image: url("../icons/fa/heart.svg");
|
||||
}
|
||||
|
||||
.icon-sliders {
|
||||
-webkit-mask-image: url("../icons/fa/sliders.svg");
|
||||
mask-image: url("../icons/fa/sliders.svg");
|
||||
}
|
||||
|
||||
.icon-seedling {
|
||||
-webkit-mask-image: url("../icons/fa/seedling.svg");
|
||||
mask-image: url("../icons/fa/seedling.svg");
|
||||
}
|
||||
|
||||
.icon-bell {
|
||||
-webkit-mask-image: url("../icons/fa/bell.svg");
|
||||
mask-image: url("../icons/fa/bell.svg");
|
||||
}
|
||||
|
||||
.icon-mobile-screen-button {
|
||||
-webkit-mask-image: url("../icons/fa/mobile-screen-button.svg");
|
||||
mask-image: url("../icons/fa/mobile-screen-button.svg");
|
||||
}
|
||||
|
||||
.icon-apple-whole {
|
||||
-webkit-mask-image: url("../icons/fa/apple-whole.svg");
|
||||
mask-image: url("../icons/fa/apple-whole.svg");
|
||||
}
|
||||
|
||||
.icon-leaf {
|
||||
-webkit-mask-image: url("../icons/fa/leaf.svg");
|
||||
mask-image: url("../icons/fa/leaf.svg");
|
||||
}
|
||||
|
||||
.mobile-sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1054,17 +1219,10 @@ legend {
|
||||
|
||||
.mobile-sheet-links {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
gap: 0.75rem;
|
||||
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;
|
||||
}
|
||||
@@ -1091,6 +1249,7 @@ legend {
|
||||
.stats-grid,
|
||||
.two-column,
|
||||
.template-library-grid,
|
||||
.settings-grid,
|
||||
.inline-form,
|
||||
.planner-entry-form,
|
||||
.planner-entry-form-wide,
|
||||
@@ -1158,6 +1317,10 @@ legend {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding-bottom: 5.6rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.6rem, 7vw, 2rem);
|
||||
}
|
||||
@@ -1174,7 +1337,9 @@ legend {
|
||||
.week-mini-grid,
|
||||
.week-overview-grid,
|
||||
.more-link-grid,
|
||||
.template-library-grid {
|
||||
.template-library-grid,
|
||||
.settings-grid,
|
||||
.card-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 112c-8.8 0-16-7.2-16-16l0-16c0-44.2 35.8-80 80-80l16 0c8.8 0 16 7.2 16 16l0 16c0 44.2-35.8 80-80 80l-16 0zM0 288c0-76.3 35.7-160 112-160 27.3 0 59.7 10.3 82.7 19.3 18.8 7.3 39.9 7.3 58.7 0 22.9-8.9 55.4-19.3 82.7-19.3 76.3 0 112 83.7 112 160 0 128-80 224-160 224-16.5 0-38.1-6.6-51.5-11.3-8.1-2.8-16.9-2.8-25 0-13.4 4.7-35 11.3-51.5 11.3-80 0-160-96-160-224z"/></svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32l0 3.2C119 50 64 114.6 64 192l0 21.7c0 48.1-16.4 94.8-46.4 132.4L7.8 358.3C2.7 364.6 0 372.4 0 380.5 0 400.1 15.9 416 35.5 416l376.9 0c19.6 0 35.5-15.9 35.5-35.5 0-8.1-2.7-15.9-7.8-22.2l-9.8-12.2C400.4 308.5 384 261.8 384 213.7l0-21.7c0-77.4-55-142-128-156.8l0-3.2c0-17.7-14.3-32-32-32zM162 464c7.1 27.6 32.2 48 62 48s54.9-20.4 62-48l-124 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 637 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M471.3 6.7C477.7 .6 487-1.6 495.6 1.2 505.4 4.5 512 13.7 512 24l0 186.9c0 131.2-108.1 237.1-238.8 237.1-77 0-143.4-49.5-167.5-118.7-35.4 30.8-57.7 76.1-57.7 126.7 0 13.3-10.7 24-24 24S0 469.3 0 456C0 381.1 38.2 315.1 96.1 276.3 131.4 252.7 173.5 240 216 240l80 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-80 0c-39.7 0-77.3 8.8-111 24.5 23.3-70 89.2-120.5 167-120.5 66.4 0 115.8-22.1 148.7-44 19.2-12.8 35.5-28.1 50.7-45.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zm64 0l0 304 224 0 0-304-224 0zM192 472c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"/></svg>
|
||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"/></svg>
|
||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 224zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384z"/></svg>
|
||||
|
After Width: | Height: | Size: 850 B |
@@ -0,0 +1,96 @@
|
||||
(() => {
|
||||
const getCsrfToken = () => {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
};
|
||||
|
||||
const getPushPublicKey = () => {
|
||||
const meta = document.querySelector('meta[name="nouri-push-public-key"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
};
|
||||
|
||||
const urlBase64ToUint8Array = (base64String) => {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0)));
|
||||
};
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
return navigator.serviceWorker.register("/service-worker.js");
|
||||
};
|
||||
|
||||
const subscribeToPush = async () => {
|
||||
const publicKey = getPushPublicKey();
|
||||
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return;
|
||||
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
const subscription = existing || await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
const subscriptionJson = subscription.toJSON();
|
||||
const payload = new URLSearchParams({
|
||||
csrf_token: getCsrfToken(),
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "",
|
||||
auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "",
|
||||
});
|
||||
await fetch("/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
body: payload.toString(),
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const unsubscribeFromPush = async () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
const payload = new URLSearchParams({ csrf_token: getCsrfToken() });
|
||||
if (subscription) {
|
||||
payload.set("endpoint", subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
await fetch("/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
body: payload.toString(),
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
registerServiceWorker();
|
||||
|
||||
const enableButton = document.querySelector("[data-push-enable]");
|
||||
const disableButton = document.querySelector("[data-push-disable]");
|
||||
|
||||
if (enableButton) {
|
||||
enableButton.addEventListener("click", () => {
|
||||
subscribeToPush().catch(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (disableButton) {
|
||||
disableButton.addEventListener("click", () => {
|
||||
unsubscribeFromPush().catch(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Nouri",
|
||||
"short_name": "Nouri",
|
||||
"description": "einfach essen planen",
|
||||
"lang": "de",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#fff6ef",
|
||||
"theme_color": "#efab72",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/brand/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const CACHE_NAME = "nouri-v0-5-0";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/static/css/styles.css",
|
||||
"/static/js/theme.js",
|
||||
"/static/js/ui.js",
|
||||
"/static/js/planner.js",
|
||||
"/static/js/pwa.js",
|
||||
"/static/brand/pwa-192.png",
|
||||
"/static/brand/pwa-512.png",
|
||||
"/static/brand/favicon.svg",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
if (!response || response.status !== 200 || response.type !== "basic") {
|
||||
return response;
|
||||
}
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || "Nouri", {
|
||||
body: data.body || "",
|
||||
icon: data.icon || "/static/brand/pwa-192.png",
|
||||
badge: data.badge || "/static/brand/pwa-badge.png",
|
||||
data: { url: data.url || "/" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(targetUrl) && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(targetUrl);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
});
|
||||