swipe, zoeken op producten, beter toevoegen in lijsten
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
const FavoriteService = require("../services/FavoriteService");
|
const FavoriteService = require("../services/FavoriteService");
|
||||||
|
const CategoryService = require("../services/CategoryService");
|
||||||
|
|
||||||
class FavoriteController {
|
class FavoriteController {
|
||||||
index(req, res) {
|
index(req, res) {
|
||||||
@@ -7,6 +8,7 @@ class FavoriteController {
|
|||||||
res.locals.title = "Favorieten";
|
res.locals.title = "Favorieten";
|
||||||
res.render("favorites/index", {
|
res.render("favorites/index", {
|
||||||
favorites,
|
favorites,
|
||||||
|
categories: CategoryService.getCategories(req.user.household_id),
|
||||||
user: req.user
|
user: req.user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const ProductRepository = require("../repositories/ProductRepository");
|
const ProductRepository = require("../repositories/ProductRepository");
|
||||||
const ShoppingListRepository = require("../repositories/ShoppingListRepository");
|
const ShoppingListRepository = require("../repositories/ShoppingListRepository");
|
||||||
const FavoriteService = require("../services/FavoriteService");
|
const FavoriteService = require("../services/FavoriteService");
|
||||||
|
const CategoryService = require("../services/CategoryService");
|
||||||
|
|
||||||
class ShoppingListController {
|
class ShoppingListController {
|
||||||
index(req, res) {
|
index(req, res) {
|
||||||
@@ -23,11 +24,15 @@ class ShoppingListController {
|
|||||||
show(req, res) {
|
show(req, res) {
|
||||||
const list = ShoppingListRepository.findById(req.params.id, req.user.household_id);
|
const list = ShoppingListRepository.findById(req.params.id, req.user.household_id);
|
||||||
if (!list) return res.status(404).render("errors/404");
|
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.locals.title = list.name;
|
||||||
res.render("lists/show", {
|
res.render("lists/show", {
|
||||||
list,
|
list,
|
||||||
items: ShoppingListRepository.getItems(list.id, req.user.household_id),
|
items,
|
||||||
products: ProductRepository.getAll(req.user.household_id),
|
products: ProductRepository.getAll(req.user.household_id)
|
||||||
|
.filter(product => !existingProductIds.has(product.id)),
|
||||||
|
categories: CategoryService.getCategories(req.user.household_id),
|
||||||
user: req.user
|
user: req.user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -39,7 +44,8 @@ class ShoppingListController {
|
|||||||
const product = Number.isInteger(productId)
|
const product = Number.isInteger(productId)
|
||||||
? ProductRepository.findById(productId, req.user.household_id)
|
? ProductRepository.findById(productId, req.user.household_id)
|
||||||
: null;
|
: 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");
|
req.flash("error", "Ongeldig product of ongeldige hoeveelheid");
|
||||||
return res.redirect(`/lists/${req.params.id}`);
|
return res.redirect(`/lists/${req.params.id}`);
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,25 @@ class ShoppingListController {
|
|||||||
res.redirect(`/lists/${list.id}`);
|
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) {
|
toggleItem(req, res) {
|
||||||
const list = ShoppingListRepository.findById(req.params.id, req.user.household_id);
|
const list = ShoppingListRepository.findById(req.params.id, req.user.household_id);
|
||||||
if (!list) return res.status(404).render("errors/404");
|
if (!list) return res.status(404).render("errors/404");
|
||||||
|
|||||||
+72
-4
@@ -4,6 +4,11 @@ body {
|
|||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1030;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -47,15 +52,78 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.list-check-button {
|
.list-check-button {
|
||||||
width: 2.5rem;
|
width: 3.25rem;
|
||||||
height: 2.5rem;
|
height: 3.25rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.35rem;
|
font-size: 1.7rem;
|
||||||
line-height: 1;
|
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,
|
.product-actions form,
|
||||||
|
|||||||
@@ -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"]');
|
const logoutLink = document.querySelector('a[href="/logout"]');
|
||||||
if (!logoutLink) return;
|
if (!logoutLink) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "pantryhub-static-v3";
|
const CACHE_NAME = "pantryhub-static-v8";
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ class ShoppingListRepository {
|
|||||||
`).run(listId, productId, amount, nextSortOrder);
|
`).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) {
|
toggleItem(listId, itemId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
UPDATE shopping_list_items
|
UPDATE shopping_list_items
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ router.post(
|
|||||||
ShoppingListController.toggleItem.bind(ShoppingListController)
|
ShoppingListController.toggleItem.bind(ShoppingListController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/lists/:id/items/:itemId/amount",
|
||||||
|
auth,
|
||||||
|
ShoppingListController.updateItemAmount.bind(ShoppingListController)
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/lists/:id/items/reset",
|
"/lists/:id/items/reset",
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
@@ -4,12 +4,30 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-4" data-product-filter>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="favorite-search">Zoek op naam</label>
|
||||||
|
<input id="favorite-search" class="form-control" type="search" placeholder="Bijv. melk" data-product-search>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="favorite-category-filter">Filter op categorie</label>
|
||||||
|
<select id="favorite-category-filter" class="form-select" data-product-category>
|
||||||
|
<option value="">Alle categorieën</option>
|
||||||
|
<% (categories || []).forEach(category => { %>
|
||||||
|
<option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% if (favorites.length === 0) { %>
|
<% if (favorites.length === 0) { %>
|
||||||
<p class="text-secondary">Je hebt nog geen favoriete producten.</p>
|
<p class="text-secondary">Je hebt nog geen favoriete producten.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="list-group">
|
<div class="list-group" data-product-list>
|
||||||
<% favorites.forEach(product => { %>
|
<% favorites.forEach(product => { %>
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center gap-3">
|
<div class="list-group-item d-flex justify-content-between align-items-center gap-3"
|
||||||
|
data-product-row
|
||||||
|
data-product-name="<%= product.name.toLowerCase() %>"
|
||||||
|
data-product-category="<%= product.category_id || '' %>">
|
||||||
<div>
|
<div>
|
||||||
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
|
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
|
||||||
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
|
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
|
||||||
@@ -21,6 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-secondary mt-3 d-none" data-product-empty>Geen favorieten gevonden.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="text-secondary small"><%= member.email %></div>
|
<div class="text-secondary small"><%= member.email %></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="badge bg-primary"><%= member.role %></span>
|
<span class="badge bg-primary text-white"><%= member.role %></span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js?v=10"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+77
-72
@@ -3,85 +3,90 @@
|
|||||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||||
<h1 class="mb-0"><%= list.name %></h1>
|
<h1 class="mb-0"><%= list.name %></h1>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<form method="post" action="/lists/<%= list.id %>/favorites/add"
|
<form method="post" action="/lists/<%= list.id %>/favorites/add" onsubmit="return confirm('Alle favorieten toevoegen aan deze lijst?');"><button class="btn btn-outline-primary" type="submit">Alle favorieten toevoegen</button></form>
|
||||||
onsubmit="return confirm('Alle favorieten toevoegen aan deze lijst?');">
|
<form method="post" action="/lists/<%= list.id %>/items/reset" onsubmit="return confirm('Alle producten weer openzetten?');"><button class="btn btn-outline-primary" type="submit">Alles weer openzetten</button></form>
|
||||||
<button class="btn btn-outline-primary" type="submit">Alle favorieten toevoegen</button>
|
<form method="post" action="/lists/<%= list.id %>/items/clear" onsubmit="return confirm('Weet je zeker dat je alle producten uit deze lijst wilt verwijderen?');"><button class="btn btn-outline-danger" type="submit">Alles verwijderen</button></form>
|
||||||
</form>
|
|
||||||
<form method="post" action="/lists/<%= list.id %>/items/reset"
|
|
||||||
onsubmit="return confirm('Alle producten weer openzetten?');">
|
|
||||||
<button class="btn btn-outline-primary" type="submit">Alles weer openzetten</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/lists/<%= list.id %>/items/clear"
|
|
||||||
onsubmit="return confirm('Weet je zeker dat je alle producten uit deze lijst wilt verwijderen?');">
|
|
||||||
<button class="btn btn-outline-danger" type="submit">Alles verwijderen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="card mb-4">
|
||||||
<div class="col-md-8">
|
<div class="card-header"><h3 class="card-title">Product toevoegen</h3></div>
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<form method="post" action="/lists/<%= list.id %>/items" class="row g-3 align-items-end" data-product-filter>
|
||||||
<% if (items.length === 0) { %>
|
<div class="col-md-5">
|
||||||
<p class="text-secondary">Nog geen producten op de lijst.</p>
|
<label class="form-label" for="product-search">Zoek op naam</label>
|
||||||
<% } else { %>
|
<input id="product-search" class="form-control" type="search" placeholder="Bijv. melk" data-product-search>
|
||||||
<div class="list-group">
|
</div>
|
||||||
<% items.forEach(item => { %>
|
<div class="col-md-3">
|
||||||
<div class="list-group-item d-flex align-items-center">
|
<label class="form-label" for="product-category-filter">Filter op categorie</label>
|
||||||
<form method="post" action="/lists/<%= list.id %>/items/<%= item.id %>/toggle">
|
<select id="product-category-filter" class="form-select" data-product-category>
|
||||||
<button class="btn btn-sm me-2 list-check-button" type="submit"
|
<option value="">Alle categorieën</option>
|
||||||
aria-label="Product <%= item.checked ? 'weer openzetten' : 'afvinken' %>"
|
<% (categories || []).forEach(category => { %><option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option><% }); %>
|
||||||
<% if (item.checked) { %>onclick="return confirm('Product weer openzetten?');"<% } %>>
|
</select>
|
||||||
<%= item.checked ? "☑" : "☐" %>
|
</div>
|
||||||
</button>
|
<div class="col-md-4">
|
||||||
</form>
|
<label class="form-label" for="product-id">Product</label>
|
||||||
<div class="flex-grow-1">
|
<select id="product-id" class="form-select" name="product_id" required data-product-select>
|
||||||
<strong<% if (item.checked) { %> style="text-decoration: line-through"<% } %>>
|
<option value="">Kies een product</option>
|
||||||
<%= item.product_name %>
|
<% (products || []).forEach(product => { %><option value="<%= product.id %>" data-product-option data-product-name="<%= product.name.toLowerCase() %>" data-product-category="<%= product.category_id || '' %>"><%= product.category_icon || "" %> <%= product.name %></option><% }); %>
|
||||||
</strong>
|
</select>
|
||||||
<% if (item.amount !== 1) { %><span class="badge bg-info text-white"><%= item.amount %></span><% } %>
|
</div>
|
||||||
<div class="small text-secondary"><%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %></div>
|
<div class="col-md-3">
|
||||||
</div>
|
<label class="form-label" for="amount">Hoeveelheid</label>
|
||||||
<form class="delete-item-form" method="post" action="/lists/<%= list.id %>/items/<%= item.id %>">
|
<input id="amount" class="form-control" type="number" name="amount" value="1" min="0.5" step="0.5" required>
|
||||||
<button class="btn btn-sm btn-link text-danger" type="submit">Verwijder</button>
|
</div>
|
||||||
</form>
|
<div class="col-md-3"><button class="btn btn-primary w-100" type="submit">Toevoegen</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<% if (items.length === 0) { %><p class="text-secondary">Nog geen producten op de lijst.</p><% } else { %>
|
||||||
|
<div class="list-group">
|
||||||
|
<% items.forEach(item => { %>
|
||||||
|
<div class="shopping-list-swipe">
|
||||||
|
<div class="swipe-action swipe-action-delete" aria-label="Product verwijderen"><span class="swipe-action-icon">🗑</span><span>Verwijder</span></div>
|
||||||
|
<div class="swipe-action swipe-action-check" aria-label="Product afvinken"><span class="swipe-action-icon">☑</span><span>Afvinken</span></div>
|
||||||
|
<div class="list-group-item d-flex align-items-center shopping-list-item"
|
||||||
|
data-item-id="<%= item.id %>"
|
||||||
|
data-product-name="<%= item.product_name %>"
|
||||||
|
data-item-amount="<%= item.amount %>"
|
||||||
|
data-toggle-url="/lists/<%= list.id %>/items/<%= item.id %>/toggle"
|
||||||
|
data-delete-url="/lists/<%= list.id %>/items/<%= item.id %>"
|
||||||
|
role="button" tabindex="0">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong<% if (item.checked) { %> style="text-decoration: line-through"<% } %>><%= item.product_name %></strong>
|
||||||
|
<div class="small text-secondary"><%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %></div>
|
||||||
</div>
|
</div>
|
||||||
<% }); %>
|
<span class="badge bg-info text-white list-item-amount"><%= item.amount %></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% } %>
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h3 class="card-title">Product toevoegen</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post" action="/lists/<%= list.id %>/items">
|
|
||||||
<label class="form-label" for="product-id">Product</label>
|
|
||||||
<select id="product-id" class="form-select mb-3" name="product_id" required>
|
|
||||||
<option value="">Kies een product</option>
|
|
||||||
<% (products || []).forEach(product => { %>
|
|
||||||
<option value="<%= product.id %>"><%= product.name %></option>
|
|
||||||
<% }); %>
|
|
||||||
</select>
|
|
||||||
<label class="form-label" for="amount">Hoeveelheid</label>
|
|
||||||
<input id="amount" class="form-control mb-3" type="number" name="amount"
|
|
||||||
value="1" min="0.5" step="0.5" required>
|
|
||||||
<button class="btn btn-primary w-100" type="submit">Toevoegen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<div class="modal fade" id="amount-modal" tabindex="-1" aria-labelledby="amount-modal-title" aria-hidden="true">
|
||||||
document.querySelectorAll(".delete-item-form").forEach((form) => {
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
form.addEventListener("submit", async (event) => {
|
<div class="modal-content">
|
||||||
event.preventDefault();
|
<form id="amount-form" method="post">
|
||||||
if (!confirm("Product verwijderen?")) return;
|
<div class="modal-header">
|
||||||
const response = await fetch(form.action, { method: "DELETE" });
|
<h2 class="modal-title fs-3" id="amount-modal-title">Hoeveelheid wijzigen</h2>
|
||||||
if (response.ok) window.location.reload();
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Sluiten"></button>
|
||||||
});
|
</div>
|
||||||
});
|
<div class="modal-body">
|
||||||
</script>
|
<p class="text-secondary mb-3" id="amount-product-name"></p>
|
||||||
|
<label class="form-label" for="modal-amount">Hoeveelheid</label>
|
||||||
|
<input id="modal-amount" class="form-control" type="number" name="amount" min="0.5" step="0.5" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuleren</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Opslaan</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/icons/icon.svg">
|
<link rel="apple-touch-icon" href="/icons/icon.svg">
|
||||||
|
|
||||||
|
|
||||||
@@ -32,5 +34,5 @@ if ("serviceWorker" in navigator) {
|
|||||||
|
|
||||||
|
|
||||||
<link
|
<link
|
||||||
href="/css/style.css"
|
href="/css/style.css?v=4"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
+72
-72
@@ -1,82 +1,82 @@
|
|||||||
<div class="page-header mb-4">
|
<div class="page-header mb-4">
|
||||||
|
<h1>📦 Producten</h1>
|
||||||
<h1>
|
|
||||||
|
|
||||||
📦 Producten
|
|
||||||
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="card mb-4">
|
||||||
<div class="col-md-8">
|
<div class="card-header"><h3 class="card-title">Nieuw product</h3></div>
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<form method="post" action="/products" class="row g-3 align-items-end">
|
||||||
<% if (products.length === 0) { %>
|
<div class="col-md-5">
|
||||||
<p class="text-secondary">Nog geen producten.</p>
|
<label class="form-label" for="product-name">Productnaam</label>
|
||||||
<% } else { %>
|
<input id="product-name" class="form-control" type="text" name="name" placeholder="bijv. Melk" maxlength="120" required>
|
||||||
<div class="list-group">
|
</div>
|
||||||
<% products.forEach(product => { %>
|
<div class="col-md-4">
|
||||||
<div class="list-group-item">
|
<label class="form-label" for="category-id">Categorie</label>
|
||||||
<div class="d-flex align-items-start justify-content-between gap-3">
|
<select id="category-id" class="form-select" name="category_id">
|
||||||
<div>
|
<option value="">Geen categorie</option>
|
||||||
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
|
<% (categories || []).forEach(category => { %>
|
||||||
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
|
<option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option>
|
||||||
</div>
|
<% }); %>
|
||||||
<div class="d-flex align-items-center gap-2">
|
</select>
|
||||||
<form method="post" action="/products/<%= product.id %>/favorite">
|
</div>
|
||||||
<button class="btn btn-sm <%= product.is_favorite ? 'btn-danger' : 'btn-outline-secondary' %>"
|
<div class="col-md-3"><button class="btn btn-primary w-100" type="submit">Product toevoegen</button></div>
|
||||||
type="submit"
|
</form>
|
||||||
aria-label="<%= product.is_favorite ? 'Verwijder uit favorieten' : 'Toevoegen aan favorieten' %>">
|
</div>
|
||||||
<%= product.is_favorite ? '♥' : '♡' %>
|
</div>
|
||||||
</button>
|
|
||||||
</form>
|
<div class="card">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/products/<%= product.id %>/edit">
|
<div class="card-body">
|
||||||
Wijzigen
|
<div class="row g-3 mb-4" data-product-filter>
|
||||||
</a>
|
<div class="col-md-8">
|
||||||
</div>
|
<label class="form-label" for="product-search">Zoek op naam</label>
|
||||||
</div>
|
<input id="product-search" class="form-control" type="search" placeholder="Bijv. melk" data-product-search>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="product-category-filter">Filter op categorie</label>
|
||||||
|
<select id="product-category-filter" class="form-select" data-product-category>
|
||||||
|
<option value="">Alle categorieën</option>
|
||||||
|
<% (categories || []).forEach(category => { %>
|
||||||
|
<option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (products.length === 0) { %>
|
||||||
|
<p class="text-secondary">Nog geen producten.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="list-group" data-product-list>
|
||||||
|
<% products.forEach(product => { %>
|
||||||
|
<div class="list-group-item" data-product-row data-product-name="<%= product.name.toLowerCase() %>" data-product-category="<%= product.category_id || '' %>">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
|
||||||
|
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
|
||||||
</div>
|
</div>
|
||||||
<% }); %>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<form method="post" action="/products/<%= product.id %>/favorite">
|
||||||
|
<button class="btn btn-sm <%= product.is_favorite ? 'btn-danger' : 'btn-outline-secondary' %>" type="submit" aria-label="<%= product.is_favorite ? 'Verwijder uit favorieten' : 'Toevoegen aan favorieten' %>"><%= product.is_favorite ? '♥' : '♡' %></button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/products/<%= product.id %>/edit">Wijzigen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="text-secondary mt-3 d-none" data-product-empty>Geen producten gevonden.</p>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="card mt-4">
|
||||||
<div class="card">
|
<div class="card-header"><h3 class="card-title">Producten in bulk laden</h3></div>
|
||||||
<div class="card-header"><h3 class="card-title">Nieuw product</h3></div>
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<form method="post" action="/products/import" enctype="multipart/form-data" class="row g-3 align-items-end">
|
||||||
<form method="post" action="/products">
|
<div class="col-md-9">
|
||||||
<label class="form-label" for="product-name">Productnaam</label>
|
<label class="form-label" for="product-import-file">CSV-bestand</label>
|
||||||
<input id="product-name" class="form-control mb-3" type="text" name="name"
|
<input id="product-import-file" class="form-control" type="file" name="file" accept=".csv,text/csv" required>
|
||||||
placeholder="bijv. Melk" maxlength="120" required>
|
|
||||||
|
|
||||||
<label class="form-label" for="category-id">Categorie</label>
|
|
||||||
<select id="category-id" class="form-select mb-3" name="category_id">
|
|
||||||
<option value="">Geen categorie</option>
|
|
||||||
<% (categories || []).forEach(category => { %>
|
|
||||||
<option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option>
|
|
||||||
<% }); %>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button class="btn btn-primary w-100" type="submit">Product toevoegen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-md-3"><button class="btn btn-outline-primary w-100" type="submit">CSV importeren</button></div>
|
||||||
|
</form>
|
||||||
<div class="card mt-3">
|
<div class="text-secondary small mt-2">Kolommen: name, category, barcode. Alleen name is verplicht.</div>
|
||||||
<div class="card-header"><h3 class="card-title">Producten in bulk laden</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post" action="/products/import" enctype="multipart/form-data">
|
|
||||||
<label class="form-label" for="product-import-file">CSV-bestand</label>
|
|
||||||
<input id="product-import-file" class="form-control mb-3" type="file" name="file"
|
|
||||||
accept=".csv,text/csv" required>
|
|
||||||
<div class="text-secondary small mb-3">Kolommen: name, category, barcode. Alleen name is verplicht.</div>
|
|
||||||
<button class="btn btn-outline-primary w-100" type="submit">CSV importeren</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user