diff --git a/controllers/FavoriteController.js b/controllers/FavoriteController.js index 3c5dcd6..0e36415 100644 --- a/controllers/FavoriteController.js +++ b/controllers/FavoriteController.js @@ -1,4 +1,5 @@ const FavoriteService = require("../services/FavoriteService"); +const CategoryService = require("../services/CategoryService"); class FavoriteController { index(req, res) { @@ -7,6 +8,7 @@ class FavoriteController { res.locals.title = "Favorieten"; res.render("favorites/index", { favorites, + categories: CategoryService.getCategories(req.user.household_id), user: req.user }); } diff --git a/controllers/ShoppingListController.js b/controllers/ShoppingListController.js index 3ca6b40..d95851b 100644 --- a/controllers/ShoppingListController.js +++ b/controllers/ShoppingListController.js @@ -1,6 +1,7 @@ const ProductRepository = require("../repositories/ProductRepository"); const ShoppingListRepository = require("../repositories/ShoppingListRepository"); const FavoriteService = require("../services/FavoriteService"); +const CategoryService = require("../services/CategoryService"); class ShoppingListController { index(req, res) { @@ -23,11 +24,15 @@ class ShoppingListController { show(req, res) { const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); if (!list) return res.status(404).render("errors/404"); + const items = ShoppingListRepository.getItems(list.id, req.user.household_id); + const existingProductIds = new Set(items.map(item => item.product_id)); res.locals.title = list.name; res.render("lists/show", { list, - items: ShoppingListRepository.getItems(list.id, req.user.household_id), - products: ProductRepository.getAll(req.user.household_id), + items, + products: ProductRepository.getAll(req.user.household_id) + .filter(product => !existingProductIds.has(product.id)), + categories: CategoryService.getCategories(req.user.household_id), user: req.user }); } @@ -39,7 +44,8 @@ class ShoppingListController { const product = Number.isInteger(productId) ? ProductRepository.findById(productId, req.user.household_id) : null; - if (!list || !product || !Number.isFinite(amount) || amount <= 0) { + if (!list || !product || ShoppingListRepository.hasProduct(list.id, product.id) || + !Number.isFinite(amount) || amount <= 0) { req.flash("error", "Ongeldig product of ongeldige hoeveelheid"); return res.redirect(`/lists/${req.params.id}`); } @@ -47,6 +53,25 @@ class ShoppingListController { res.redirect(`/lists/${list.id}`); } + updateItemAmount(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + const amount = Number.parseFloat(req.body.amount || ""); + if (!list || !Number.isFinite(amount) || amount <= 0) { + req.flash("error", "Geef een geldige hoeveelheid op."); + return res.redirect(`/lists/${req.params.id}`); + } + + const result = ShoppingListRepository.updateItemAmount(list.id, req.params.itemId, amount); + if (result.changes === 0) { + req.flash("error", "Product niet gevonden op deze lijst."); + return res.redirect(`/lists/${req.params.id}`); + } + + this.broadcastUpdate(req, list.id); + req.flash("success", "Hoeveelheid gewijzigd."); + res.redirect(`/lists/${list.id}`); + } + toggleItem(req, res) { const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); if (!list) return res.status(404).render("errors/404"); diff --git a/public/css/style.css b/public/css/style.css index 79798ca..3961d33 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -4,6 +4,11 @@ body { .navbar { margin-bottom: 25px; + position: sticky; + top: 0; + z-index: 1030; + background-color: #ffffff; + box-shadow: 0 1px 6px rgba(0, 0, 0, .08); } .card { @@ -47,15 +52,78 @@ body { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: .5rem; margin-top: 1.5rem; + align-items: stretch; +} .list-check-button { - width: 2.5rem; - height: 2.5rem; + width: 3.25rem; + height: 3.25rem; padding: 0; - font-size: 1.35rem; + font-size: 1.7rem; line-height: 1; + flex-shrink: 0; } - align-items: stretch; + +.shopping-list-item { + position: relative; + z-index: 1; + cursor: pointer; + transition: transform .2s ease; + touch-action: pan-y; +} + +.shopping-list-item:hover, +.shopping-list-item:focus-visible { + background-color: rgba(32, 107, 196, .06); + outline: none; +} + +.list-item-amount { + margin-left: auto; + flex-shrink: 0; +} + +.shopping-list-swipe { + position: relative; + overflow: hidden; +} + +.swipe-action { + position: absolute; + inset: 0; + display: flex; + align-items: center; + width: 7rem; + padding: 0 1rem; + color: #fff; + font-weight: 600; + opacity: 0; + transition: opacity .15s ease; + gap: .4rem; +} + +.shopping-list-swipe.is-swiping.swipe-left .swipe-action-check, +.shopping-list-swipe.is-swiping.swipe-right .swipe-action-delete { + opacity: 1; +} + +.swipe-action-delete { + left: 0; + right: auto; + justify-content: flex-start; + background: #d63939; +} + +.swipe-action-check { + right: 0; + left: auto; + justify-content: flex-end; + background: #2fb344; +} + +.swipe-action-icon { + font-size: 1.5rem; + line-height: 1; } .product-actions form, diff --git a/public/js/app.js b/public/js/app.js index b39d0a4..a6cddea 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,4 +1,141 @@ (() => { + document.querySelectorAll("[data-product-filter]").forEach((filter) => { + const search = filter.querySelector("[data-product-search]"); + const category = filter.querySelector("[data-product-category]"); + const list = filter.closest(".card")?.querySelector("[data-product-list]"); + const rows = list ? [...list.querySelectorAll("[data-product-row]")] : []; + const select = filter.querySelector("[data-product-select]"); + const options = select ? [...select.querySelectorAll("[data-product-option]")] : []; + const empty = list?.parentElement.querySelector("[data-product-empty]"); + + const update = () => { + const query = (search?.value || "").trim().toLowerCase(); + const selectedCategory = category?.value || ""; + const matches = (element) => { + const nameMatches = !query || element.dataset.productName.includes(query); + const categoryMatches = !selectedCategory || element.dataset.productCategory === selectedCategory; + return nameMatches && categoryMatches; + }; + + rows.forEach((row) => { row.classList.toggle("d-none", !matches(row)); }); + options.forEach((option) => { option.hidden = !matches(option); }); + if (empty) empty.classList.toggle("d-none", rows.some((row) => !row.classList.contains("d-none"))); + }; + + search?.addEventListener("input", update); + category?.addEventListener("change", update); + update(); + }); + + const amountModal = document.getElementById("amount-modal"); + const amountForm = document.getElementById("amount-form"); + const amountInput = document.getElementById("modal-amount"); + const amountProductName = document.getElementById("amount-product-name"); + + if (amountModal && amountForm && amountInput && amountProductName) { + let backdrop; + + const closeAmountModal = () => { + amountModal.classList.remove("show"); + amountModal.style.display = "none"; + amountModal.setAttribute("aria-hidden", "true"); + document.body.classList.remove("modal-open"); + backdrop?.remove(); + }; + + const openAmountModal = (item) => { + amountForm.action = `/lists/${window.location.pathname.split("/")[2]}/items/${item.dataset.itemId}/amount`; + amountProductName.textContent = item.dataset.productName; + amountInput.value = item.dataset.itemAmount; + amountModal.classList.add("show"); + amountModal.style.display = "block"; + amountModal.setAttribute("aria-hidden", "false"); + document.body.classList.add("modal-open"); + backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop fade show"; + backdrop.addEventListener("click", closeAmountModal); + document.body.append(backdrop); + amountInput.focus(); + }; + + document.querySelectorAll(".shopping-list-item").forEach((item) => { + const swipeContainer = item.closest(".shopping-list-swipe"); + let touchStartX = 0; + let touchStartY = 0; + let swiped = false; + let touchMoved = false; + + item.addEventListener("click", (event) => { + if (swiped) { + swiped = false; + event.preventDefault(); + return; + } + if (event.target.closest("form, button, a, input, select")) return; + openAmountModal(item); + }); + item.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openAmountModal(item); + } + }); + + item.addEventListener("touchstart", (event) => { + const touch = event.changedTouches[0]; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + swiped = false; + touchMoved = false; + swipeContainer?.classList.remove("is-swiping"); + swipeContainer?.classList.remove("swipe-left", "swipe-right"); + }, { passive: true }); + + item.addEventListener("touchmove", (event) => { + const touch = event.changedTouches[0]; + const deltaX = touch.clientX - touchStartX; + const deltaY = touch.clientY - touchStartY; + if (Math.abs(deltaX) <= Math.abs(deltaY) || Math.abs(deltaX) < 8) return; + + touchMoved = true; + event.preventDefault(); + swipeContainer?.classList.add("is-swiping"); + swipeContainer?.classList.toggle("swipe-left", deltaX < 0); + swipeContainer?.classList.toggle("swipe-right", deltaX > 0); + item.style.transform = `translateX(${Math.max(-120, Math.min(120, deltaX))}px)`; + }, { passive: false }); + + item.addEventListener("touchend", (event) => { + const touch = event.changedTouches[0]; + const deltaX = touch.clientX - touchStartX; + const deltaY = touch.clientY - touchStartY; + item.style.transform = ""; + swipeContainer?.classList.remove("is-swiping"); + swipeContainer?.classList.remove("swipe-left", "swipe-right"); + if (!touchMoved || Math.abs(deltaX) < 70 || Math.abs(deltaX) < Math.abs(deltaY)) return; + + swiped = true; + event.preventDefault(); + if (deltaX < 0) { + fetch(item.dataset.toggleUrl, { method: "POST" }).then((response) => { + if (response.ok) window.location.reload(); + }); + } else if (confirm("Product verwijderen?")) { + fetch(item.dataset.deleteUrl, { method: "DELETE" }).then((response) => { + if (response.ok) window.location.reload(); + }); + } + }, { passive: false }); + }); + + amountModal.querySelectorAll("[data-bs-dismiss='modal']").forEach((button) => { + button.addEventListener("click", closeAmountModal); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && amountModal.classList.contains("show")) closeAmountModal(); + }); + } + const logoutLink = document.querySelector('a[href="/logout"]'); if (!logoutLink) return; diff --git a/public/service-worker.js b/public/service-worker.js index e7aadf2..14c27ef 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "pantryhub-static-v3"; +const CACHE_NAME = "pantryhub-static-v8"; self.addEventListener("install", () => { self.skipWaiting(); diff --git a/repositories/ShoppingListRepository.js b/repositories/ShoppingListRepository.js index c15088f..398af6e 100644 --- a/repositories/ShoppingListRepository.js +++ b/repositories/ShoppingListRepository.js @@ -56,6 +56,22 @@ class ShoppingListRepository { `).run(listId, productId, amount, nextSortOrder); } + hasProduct(listId, productId) { + return db.prepare(` + SELECT 1 FROM shopping_list_items + WHERE list_id = ? AND product_id = ? + LIMIT 1 + `).get(listId, productId) !== undefined; + } + + updateItemAmount(listId, itemId, amount) { + return db.prepare(` + UPDATE shopping_list_items + SET amount = ? + WHERE id = ? AND list_id = ? + `).run(amount, itemId, listId); + } + toggleItem(listId, itemId) { return db.prepare(` UPDATE shopping_list_items diff --git a/routes/web.js b/routes/web.js index aec90df..f5480e5 100644 --- a/routes/web.js +++ b/routes/web.js @@ -199,6 +199,12 @@ router.post( ShoppingListController.toggleItem.bind(ShoppingListController) ); +router.post( + "/lists/:id/items/:itemId/amount", + auth, + ShoppingListController.updateItemAmount.bind(ShoppingListController) +); + router.post( "/lists/:id/items/reset", auth, diff --git a/views/favorites/index.ejs b/views/favorites/index.ejs index e81ee47..afa0ce3 100644 --- a/views/favorites/index.ejs +++ b/views/favorites/index.ejs @@ -4,12 +4,30 @@
+
+
+ + +
+
+ + +
+
<% if (favorites.length === 0) { %>

Je hebt nog geen favoriete producten.

<% } else { %> -
+
<% favorites.forEach(product => { %> -
+
<%= product.category_icon || "" %> <%= product.name %>
<%= product.category_name || "Geen categorie" %>
@@ -21,6 +39,7 @@
<% }); %>
+

Geen favorieten gevonden.

<% } %>
diff --git a/views/household/index.ejs b/views/household/index.ejs index 286ddb2..1268806 100644 --- a/views/household/index.ejs +++ b/views/household/index.ejs @@ -30,7 +30,7 @@
<%= member.email %>
- <%= member.role %> + <%= member.role %>
diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index 26b928d..800a601 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -38,7 +38,7 @@ - + diff --git a/views/lists/show.ejs b/views/lists/show.ejs index 2fa2169..59d2b8d 100644 --- a/views/lists/show.ejs +++ b/views/lists/show.ejs @@ -3,85 +3,90 @@

<%= list.name %>

-
- -
-
- -
-
- -
+
+
+
-
-
-
-
- <% if (items.length === 0) { %> -

Nog geen producten op de lijst.

- <% } else { %> -
- <% items.forEach(item => { %> -
-
- -
-
- style="text-decoration: line-through"<% } %>> - <%= item.product_name %> - - <% if (item.amount !== 1) { %><%= item.amount %><% } %> -
<%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %>
-
-
- -
+
+

Product toevoegen

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ <% if (items.length === 0) { %>

Nog geen producten op de lijst.

<% } else { %> +
+ <% items.forEach(item => { %> +
+
🗑Verwijder
+
Afvinken
+
+
+ style="text-decoration: line-through"<% } %>><%= item.product_name %> +
<%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %>
- <% }); %> + <%= item.amount %> +
- <% } %> + <% }); %>
-
-
-
-
-

Product toevoegen

-
-
- - - - - -
-
-
+ <% } %>
- + diff --git a/views/partials/head.ejs b/views/partials/head.ejs index aecf174..34becf7 100644 --- a/views/partials/head.ejs +++ b/views/partials/head.ejs @@ -12,6 +12,8 @@ + + @@ -32,5 +34,5 @@ if ("serviceWorker" in navigator) { \ No newline at end of file diff --git a/views/products/index.ejs b/views/products/index.ejs index ddb6a35..dfe1aa9 100644 --- a/views/products/index.ejs +++ b/views/products/index.ejs @@ -1,82 +1,82 @@ -
-
-
-
- <% if (products.length === 0) { %> -

Nog geen producten.

- <% } else { %> -
- <% products.forEach(product => { %> -
-
-
- <%= product.category_icon || "" %> <%= product.name %> -
<%= product.category_name || "Geen categorie" %>
-
-
-
- -
- - Wijzigen - -
-
+
+

Nieuw product

+
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ <% if (products.length === 0) { %> +

Nog geen producten.

+ <% } else { %> +
+ <% products.forEach(product => { %> +
+
+
+ <%= product.category_icon || "" %> <%= product.name %> +
<%= product.category_name || "Geen categorie" %>
- <% }); %> +
+
+ +
+ Wijzigen +
+
- <% } %> + <% }); %>
-
+

Geen producten gevonden.

+ <% } %>
+
-
-
-

Nieuw product

-
-
- - - - - - - -
+
+

Producten in bulk laden

+
+
+
+ +
-
- -
-

Producten in bulk laden

-
- - - -
Kolommen: name, category, barcode. Alleen name is verplicht.
- - -
-
+
+ +
Kolommen: name, category, barcode. Alleen name is verplicht.
-
\ No newline at end of file +