Compare commits
8 commits
e798686ef9
...
2f07379289
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f07379289 | |||
| a64591bc2f | |||
| 03c528f8ba | |||
| eddf7cd50b | |||
| 2be329c4b7 | |||
| 3190b8dd92 | |||
| 9cffcec304 | |||
| e05c36b647 |
BIN
.github/screenshots/header.png
vendored
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
.github/screenshots/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
.github/screenshots/mobile_dark_1.png
vendored
Normal file
|
After Width: | Height: | Size: 952 KiB |
BIN
.github/screenshots/mobile_dark_2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
.github/screenshots/mobile_light_1.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
.github/screenshots/mobile_light_2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
.github/screenshots/preview.png
vendored
|
Before Width: | Height: | Size: 5.7 MiB |
BIN
.github/screenshots/small_products.png
vendored
|
Before Width: | Height: | Size: 311 KiB |
BIN
.github/screenshots/small_settings.png
vendored
|
Before Width: | Height: | Size: 123 KiB |
BIN
.github/screenshots/wide_dark_1.png
vendored
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
.github/screenshots/wide_dark_2.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
.github/screenshots/wide_dark_3.png
vendored
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
.github/screenshots/wide_light_1.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
.github/screenshots/wide_light_2.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
.github/screenshots/wide_light_3.png
vendored
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
.github/screenshots/wide_products.png
vendored
|
Before Width: | Height: | Size: 675 KiB |
BIN
.github/screenshots/wide_settings.png
vendored
|
Before Width: | Height: | Size: 249 KiB |
|
|
@ -1,3 +1,7 @@
|
||||||
|

|
||||||
|
|
||||||
|
> Weitere Screenshots sind im Ordner `.github/screenshots` zu finden.
|
||||||
|
|
||||||
# Der Durstrechner
|
# Der Durstrechner
|
||||||
|
|
||||||
Der Durstrechner ist eine einfache Webanwendung, die den Getränkeverkauf bei Veranstaltungen unterstützt.
|
Der Durstrechner ist eine einfache Webanwendung, die den Getränkeverkauf bei Veranstaltungen unterstützt.
|
||||||
|
|
@ -6,9 +10,6 @@ ist auch offline verfügbar und kann ohne Internetverbindung genutzt werden (Ser
|
||||||
Einrichtung wird eine Internetverbindung empfohlen (das einmalige Aufrufen der Seite im Browser mit einer Internetverbindung
|
Einrichtung wird eine Internetverbindung empfohlen (das einmalige Aufrufen der Seite im Browser mit einer Internetverbindung
|
||||||
genügt).
|
genügt).
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
> Weitere Screenshots sind im Ordner `.github/screenshots` zu finden.
|
|
||||||
|
|
||||||
## Anleitung und Hinweise
|
## Anleitung und Hinweise
|
||||||
|
|
||||||
|
|
@ -26,4 +27,3 @@ pnpm install
|
||||||
# Startet den Entwicklungsserver
|
# Startet den Entwicklungsserver
|
||||||
npx http-server -o .
|
npx http-server -o .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
62
assets/example/example.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 75086086956949000,
|
||||||
|
"name": "Wasser",
|
||||||
|
"price": "0.5",
|
||||||
|
"image": "./assets/example/images/wasser.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 403980494034923100,
|
||||||
|
"name": "Cola",
|
||||||
|
"price": "1",
|
||||||
|
"image": "./assets/example/images/cola.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 174111295271372860,
|
||||||
|
"name": "Fanta",
|
||||||
|
"price": "1",
|
||||||
|
"image": "./assets/example/images/fanta.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13086165444583086,
|
||||||
|
"name": "Bier",
|
||||||
|
"price": "1.5",
|
||||||
|
"image": "./assets/example/images/bier.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 227920082494117630,
|
||||||
|
"name": "Helles Bier",
|
||||||
|
"price": "1.2",
|
||||||
|
"image": "./assets/example/images/bier_helles.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27617349687000040,
|
||||||
|
"name": "Apfelsaft",
|
||||||
|
"price": "1.2",
|
||||||
|
"image": "./assets/example/images/apfelsaft.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 191783545996520450,
|
||||||
|
"name": "Eistee",
|
||||||
|
"price": "1.5",
|
||||||
|
"image": "./assets/example/images/eistee.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 203321763516746460,
|
||||||
|
"name": "Cider",
|
||||||
|
"price": "1.8",
|
||||||
|
"image": "./assets/example/images/cider.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 615216202376277900,
|
||||||
|
"name": "Zitronenlimonade",
|
||||||
|
"price": "1",
|
||||||
|
"image": "./assets/example/images/limonade.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 363150314621734500,
|
||||||
|
"name": "Wodka Lemon",
|
||||||
|
"price": "3",
|
||||||
|
"image": "./assets/example/images/wodka_lemon.webp"
|
||||||
|
}
|
||||||
|
]
|
||||||
62
assets/example/example_urls.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 75086086956949000,
|
||||||
|
"name": "Wasser",
|
||||||
|
"price": "0.5",
|
||||||
|
"image": "https://placehold.co/250x250?text=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 403980494034923100,
|
||||||
|
"name": "Cola",
|
||||||
|
"price": "1",
|
||||||
|
"image": "https://placehold.co/250x250?text=2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 174111295271372860,
|
||||||
|
"name": "Fanta",
|
||||||
|
"price": "1",
|
||||||
|
"image": "https://placehold.co/250x250?text=3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13086165444583086,
|
||||||
|
"name": "Bier",
|
||||||
|
"price": "1.5",
|
||||||
|
"image": "https://placehold.co/250x250?text=4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 227920082494117630,
|
||||||
|
"name": "Helles Bier",
|
||||||
|
"price": "1.2",
|
||||||
|
"image": "https://placehold.co/250x250?text=5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27617349687000040,
|
||||||
|
"name": "Apfelsaft",
|
||||||
|
"price": "1.2",
|
||||||
|
"image": "https://placehold.co/250x250?text=6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 191783545996520450,
|
||||||
|
"name": "Eistee",
|
||||||
|
"price": "1.5",
|
||||||
|
"image": "https://placehold.co/250x250?text=7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 203321763516746460,
|
||||||
|
"name": "Cider",
|
||||||
|
"price": "1.8",
|
||||||
|
"image": "https://placehold.co/250x250?text=8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 615216202376277900,
|
||||||
|
"name": "Zitronenlimonade",
|
||||||
|
"price": "1",
|
||||||
|
"image": "https://placehold.co/250x250?text=9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 363150314621734500,
|
||||||
|
"name": "Wodka Lemon",
|
||||||
|
"price": "3",
|
||||||
|
"image": "https://placehold.co/250x250?text=10"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
assets/example/images/apfelsaft.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
assets/example/images/bier.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/example/images/bier_helles.webp
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
assets/example/images/cider.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
assets/example/images/cola.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
assets/example/images/eistee.webp
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
assets/example/images/fanta.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
assets/example/images/limonade.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/example/images/wasser.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/example/images/wodka_lemon.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 966 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -3,32 +3,37 @@
|
||||||
"short_name": "Durstrechner",
|
"short_name": "Durstrechner",
|
||||||
"description": "Der Durstrechner ist ein einfaches Tool zur Berechnung von Verkaufspreisen bei Veranstaltungen.",
|
"description": "Der Durstrechner ist ein einfaches Tool zur Berechnung von Verkaufspreisen bei Veranstaltungen.",
|
||||||
"start_url": "../",
|
"start_url": "../",
|
||||||
"scope": ".",
|
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#2196f3",
|
"theme_color": "#2196f3",
|
||||||
"screenshots": [
|
"screenshots": [
|
||||||
{
|
{
|
||||||
"src": "pwa/dark.png",
|
"src": "pwa/logo.png",
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "1064x2008"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "pwa/light.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "1064x2008"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "pwa/wide_dark.png",
|
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
|
"sizes": "1024x1024",
|
||||||
"form_factor": "wide",
|
"form_factor": "wide",
|
||||||
"sizes": "3604x2008"
|
"label": "Durstrechner Logo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "pwa/wide_light.png",
|
"src": "pwa/wide.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
|
"sizes": "2988x2014",
|
||||||
"form_factor": "wide",
|
"form_factor": "wide",
|
||||||
"sizes": "3604x2008"
|
"label": "Anzeige der Webseite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "pwa/logo.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1024x1024",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "Durstrechner Logo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "pwa/tall.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1092x2016",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "Anzeige der Webseite"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 146 KiB |
BIN
assets/pwa/logo.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/pwa/tall.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/pwa/wide.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
|
@ -1,6 +1,7 @@
|
||||||
import './calculator.js'
|
/**
|
||||||
import './../../node_modules/bootstrap/dist/js/bootstrap.min.js'
|
* This imports the necessary files and registers the service worker.
|
||||||
|
*/
|
||||||
|
import './../../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('./assets/script/service-worker.js')
|
navigator.serviceWorker.register('./assets/script/service-worker.js')
|
||||||
|
|
@ -8,3 +9,657 @@ if ('serviceWorker' in navigator) {
|
||||||
.catch(err => console.error('Service Worker fehlgeschlagen', err));
|
.catch(err => console.error('Service Worker fehlgeschlagen', err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Element class provides static methods to get various elements from the DOM.
|
||||||
|
*/
|
||||||
|
class Element {
|
||||||
|
/**
|
||||||
|
* Get the import/export textarea element
|
||||||
|
* @returns {HTMLTextAreaElement}
|
||||||
|
*/
|
||||||
|
static getImportExportTextarea() {
|
||||||
|
return document.querySelector('#input-export-import');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the product list element
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getProductListElement() {
|
||||||
|
return document.querySelector('.product-list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the settings product list element
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getSettingListElement() {
|
||||||
|
return document.querySelector('.settings-product-list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cart element
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getCartElement() {
|
||||||
|
return document.querySelector('.cart-items');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cart empty alert element
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getCartEmptyAlertElement() {
|
||||||
|
return document.querySelector('.alert.cart-empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the create new product button element
|
||||||
|
* @returns {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
static getCreateNewProductButton() {
|
||||||
|
return document.querySelector('#create-product');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cart button element
|
||||||
|
* @returns {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
static getCartButton() {
|
||||||
|
return document.querySelector('button.cart-value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the navbar brand element
|
||||||
|
* @returns {Element}
|
||||||
|
*/
|
||||||
|
static getNavbarBrand() {
|
||||||
|
return document.querySelector('.navbar-brand');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getButtonsImportTestdata() {
|
||||||
|
return document.querySelectorAll('[data-action=import-testdata]');
|
||||||
|
}
|
||||||
|
static getButtonShowTestdata() {
|
||||||
|
return document.querySelectorAll('[data-action=import-show-testdata]');
|
||||||
|
}
|
||||||
|
static getButtonClearTestdata() {
|
||||||
|
return document.querySelectorAll('[data-action=import-clear]');
|
||||||
|
}
|
||||||
|
static getGitHubReferenceLink() {
|
||||||
|
return document.querySelector('[data-github-ref]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TemplateElement class provides static methods to get various templates from the DOM.
|
||||||
|
*/
|
||||||
|
class TemplateElement {
|
||||||
|
/**
|
||||||
|
* Get the product list element template
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getProductTemplate() {
|
||||||
|
const element = document.querySelector('[data-template=product]').cloneNode(true);
|
||||||
|
return this.removeTemplate(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cart line element template
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getCartLineTemplate() {
|
||||||
|
const element = document.querySelector('[data-template=product-line-item]').cloneNode(true);
|
||||||
|
return this.removeTemplate(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the settings product element template
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static getSettingsProductTemplate() {
|
||||||
|
const element = document.querySelector('[data-template=settings-product]').cloneNode(true);
|
||||||
|
return this.removeTemplate(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the template attribute from the element
|
||||||
|
* @param element {HTMLElement}
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static removeTemplate(element) {
|
||||||
|
element.removeAttribute('data-template');
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product class represents a product with an id, name, price, and image.
|
||||||
|
*/
|
||||||
|
class Product {
|
||||||
|
/**
|
||||||
|
* Product constructor
|
||||||
|
* @param name {string} Name of the product
|
||||||
|
* @param price {number} Price of the product
|
||||||
|
* @param image {string} Image of the product (source url or base64)
|
||||||
|
*/
|
||||||
|
constructor(name, price, image) {
|
||||||
|
this.id = Product.generateId();
|
||||||
|
this.name = name;
|
||||||
|
this.price = price;
|
||||||
|
this.image = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random id for the product
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
static generateId() {
|
||||||
|
return Math.floor(Math.random() * 1000000000) * Math.floor(Math.random() * 1000000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the product html element by template and set the attributes by the product object
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
getProductElement() {
|
||||||
|
const productElement = TemplateElement.getProductTemplate();
|
||||||
|
productElement.setAttribute('data-id', this.id);
|
||||||
|
productElement.querySelector('[data-attr=name]').textContent = this.name;
|
||||||
|
productElement.querySelector('[data-attr=price]').textContent = CartManager.getNumberFormatter().format(this.price);
|
||||||
|
productElement.querySelector('[data-attr=image]').src = this.image;
|
||||||
|
productElement.addEventListener('click', () => {
|
||||||
|
cartManager.addProduct(this);
|
||||||
|
})
|
||||||
|
return productElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the settings product html element by template and set the attributes by the product object
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
getSettingsProductElement() {
|
||||||
|
const productElement = TemplateElement.getSettingsProductTemplate();
|
||||||
|
productElement.setAttribute('data-id', this.id);
|
||||||
|
productElement.textContent = this.name;
|
||||||
|
productElement.addEventListener('click', () => {
|
||||||
|
productManager.removeProduct(this);
|
||||||
|
productManager.setExportFieldJsonValue();
|
||||||
|
productElement.remove();
|
||||||
|
})
|
||||||
|
return productElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CartLine class represents a line in the cart with a product and its quantity.
|
||||||
|
*/
|
||||||
|
class CartLine {
|
||||||
|
/**
|
||||||
|
* CartLine constructor
|
||||||
|
* @param product {Product}
|
||||||
|
* @param quantity {number}
|
||||||
|
*/
|
||||||
|
constructor(product, quantity) {
|
||||||
|
this.product = product;
|
||||||
|
this.quantity = quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64Image class provides static methods to convert an image URL or file to a base64 string.
|
||||||
|
*/
|
||||||
|
class Base64Image {
|
||||||
|
/**
|
||||||
|
* Convert an image URL to a base64 string
|
||||||
|
* @param url
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
static fromImageUrl(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
resolve(dataUrl);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an image from a base64 string to a smaller size
|
||||||
|
* @param base64
|
||||||
|
* @returns {Promise<unknown>}
|
||||||
|
*/
|
||||||
|
static imageResize(base64) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 250;
|
||||||
|
canvas.height = 250;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
resolve(dataUrl);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = base64;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file to a base64 string
|
||||||
|
* @param file
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
static fromFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductManager class manages the product list, including loading from JSON, adding, and removing products.
|
||||||
|
*/
|
||||||
|
class ProductManager {
|
||||||
|
constructor() {
|
||||||
|
this.loadFromJson(localStorage.getItem('products') ?? [])
|
||||||
|
this.setExportFieldJsonValue();
|
||||||
|
this.registerSettingsFormEvents();
|
||||||
|
this.registerImportFormEvents();
|
||||||
|
this.registerNavbarBrandToProductEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the event for the navbar brand to switch to the products tab when clicked.
|
||||||
|
*/
|
||||||
|
registerNavbarBrandToProductEvent() {
|
||||||
|
Element.getNavbarBrand().addEventListener('click', () => {
|
||||||
|
TabManager.switchTab('products');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register events for the import/export textarea.
|
||||||
|
*/
|
||||||
|
registerImportFormEvents() {
|
||||||
|
Element.getButtonsImportTestdata().forEach(button => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
await fetch('assets/example/example.json').then(r => r.json()).then(async json => {
|
||||||
|
await this.convertAndSaveProductImages(json);
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
Element.getImportExportTextarea().addEventListener('input', async (e) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(e.target.value);
|
||||||
|
await this.convertAndSaveProductImages(json);
|
||||||
|
location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert and save product images from JSON string to base64 and save it to local storage.
|
||||||
|
* @param json
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async convertAndSaveProductImages(json) {
|
||||||
|
const products = json.map(e => new Product(e.name, e.price, e.image));
|
||||||
|
for (const product of products) {
|
||||||
|
if (!product.image.toString().startsWith('data:image/')) {
|
||||||
|
product.image = await Base64Image.imageResize(await Base64Image.fromImageUrl(product.image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem('products', JSON.stringify(products));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the JSON value of the export field to the current products.
|
||||||
|
*/
|
||||||
|
setExportFieldJsonValue() {
|
||||||
|
Element.getImportExportTextarea().textContent = JSON.stringify(this.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load products from JSON string and parse it into Product objects, then render the product list.
|
||||||
|
* @param json
|
||||||
|
*/
|
||||||
|
loadFromJson(json) {
|
||||||
|
let parse = [];
|
||||||
|
try {
|
||||||
|
parse = JSON.parse(json);
|
||||||
|
} catch (e) {}
|
||||||
|
this.products = parse.map(e => new Product(e.name, e.price, e.image));
|
||||||
|
this.renderProductList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register events for the create new product button.
|
||||||
|
*/
|
||||||
|
registerSettingsFormEvents() {
|
||||||
|
Element.getCreateNewProductButton().addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = document.querySelector('#product-name').value;
|
||||||
|
const price = document.querySelector('#product-price').value;
|
||||||
|
const image = document.querySelector('#product-image').files[0];
|
||||||
|
|
||||||
|
// Validate price
|
||||||
|
if (name.length === 0) {
|
||||||
|
alert('Name muss ausgefüllt sein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn image into base64 or use placeholder image
|
||||||
|
if (image) {
|
||||||
|
const imageSrcWithBase64 = Base64Image.fromFile(image);
|
||||||
|
imageSrcWithBase64.then(async imageSrcWithBase64 => {
|
||||||
|
productManager.addProduct(new Product(name, price, await Base64Image.imageResize(imageSrcWithBase64)));
|
||||||
|
ProductManager.resetProductSettingsForm();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let image1 = await Base64Image.imageResize(await Base64Image.fromImageUrl(`https://placehold.co/250x250?text=${name}`));
|
||||||
|
productManager.addProduct(new Product(name, price, image1));
|
||||||
|
ProductManager.resetProductSettingsForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the product settings form by clearing all input fields.
|
||||||
|
*/
|
||||||
|
static resetProductSettingsForm() {
|
||||||
|
document.querySelectorAll('form input:not([type=submit])').forEach(inputField => inputField.value = '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the product list by clearing the existing elements and appending new ones.
|
||||||
|
*/
|
||||||
|
renderProductList() {
|
||||||
|
// Clear the product list element before rendering except the template
|
||||||
|
Element.getProductListElement().querySelectorAll('[data-id]').forEach(e => {
|
||||||
|
if (e.getAttribute('data-template') === null) {
|
||||||
|
e.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.products.forEach(e => {
|
||||||
|
Element.getProductListElement().appendChild(e.getProductElement());
|
||||||
|
Element.getSettingListElement().appendChild(e.getSettingsProductElement());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new product to the list and save it to local storage.
|
||||||
|
* @param product {Product}
|
||||||
|
*/
|
||||||
|
addProduct(product) {
|
||||||
|
this.products.push(product);
|
||||||
|
localStorage.setItem('products', JSON.stringify(this.products));
|
||||||
|
Element.getProductListElement().appendChild(product.getProductElement());
|
||||||
|
Element.getSettingListElement().appendChild(product.getSettingsProductElement());
|
||||||
|
this.setExportFieldJsonValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a product from the list and local storage.
|
||||||
|
* @param product {Product}
|
||||||
|
*/
|
||||||
|
removeProduct(product) {
|
||||||
|
this.products = this.products.filter(e => e.id !== product.id);
|
||||||
|
const productElement = Element.getProductListElement().querySelector(`[data-id="${product.id}"]`);
|
||||||
|
if (productElement) {
|
||||||
|
productElement.remove();
|
||||||
|
}
|
||||||
|
localStorage.setItem('products', JSON.stringify(this.products));
|
||||||
|
this.setExportFieldJsonValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CartManager class manages the cart, including adding, removing products, and rendering the cart.
|
||||||
|
*/
|
||||||
|
class CartManager {
|
||||||
|
constructor() {
|
||||||
|
this.cartLines = [];
|
||||||
|
this.registerCartResetEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the cart reset event to clear the cart when the button is clicked.
|
||||||
|
*/
|
||||||
|
registerCartResetEvent() {
|
||||||
|
Element.getCartButton().addEventListener('click', () => {
|
||||||
|
CartHistoryManager.addToTotal(this.cartLines.reduce((acc, cartLine) => {
|
||||||
|
return acc + (cartLine.product.price * cartLine.quantity);
|
||||||
|
}, 0));
|
||||||
|
this.cartLines = [];
|
||||||
|
this.renderCart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a product to the cart. If the product already exists, increase the quantity.
|
||||||
|
* @param product {Product}
|
||||||
|
*/
|
||||||
|
addProduct(product) {
|
||||||
|
const cartLine = this.cartLines.find(e => e.product.id === product.id);
|
||||||
|
if (cartLine) {
|
||||||
|
cartLine.quantity++;
|
||||||
|
} else {
|
||||||
|
this.cartLines.push(new CartLine(product, 1));
|
||||||
|
}
|
||||||
|
this.renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a product from the cart. If the quantity is 0, remove the product from the cart.
|
||||||
|
* @param product {Product}
|
||||||
|
*/
|
||||||
|
removeProduct(product) {
|
||||||
|
const cartLine = this.cartLines.find(e => e.product.id === product.id);
|
||||||
|
if (cartLine) {
|
||||||
|
cartLine.quantity--;
|
||||||
|
if (cartLine.quantity <= 0) {
|
||||||
|
this.cartLines = this.cartLines.filter(e => e.product.id !== product.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the cart by clearing the existing elements and appending new ones.
|
||||||
|
*/
|
||||||
|
renderCart() {
|
||||||
|
// Clear the cart element before rendering except the template
|
||||||
|
Element.getCartElement().querySelectorAll('[data-id]').forEach(e => {
|
||||||
|
if (e.getAttribute('data-template') === null) {
|
||||||
|
e.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(this.cartLines.length === 0) {
|
||||||
|
Element.getCartEmptyAlertElement().classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
Element.getCartEmptyAlertElement().classList.add('d-none');
|
||||||
|
}
|
||||||
|
this.calculateCartValue();
|
||||||
|
|
||||||
|
// Render each cart line
|
||||||
|
this.cartLines.forEach(cartLine => {
|
||||||
|
const cartLineElement = this.getCartLineElement(cartLine);
|
||||||
|
Element.getCartElement().appendChild(cartLineElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cart line html element and set the attributes by the product object
|
||||||
|
* @param cartLine {CartLine}
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
getCartLineElement(cartLine) {
|
||||||
|
const cartLineElement = TemplateElement.getCartLineTemplate();
|
||||||
|
cartLineElement.setAttribute('data-id', cartLine.product.id);
|
||||||
|
cartLineElement.querySelector('[data-attr=name]').textContent = cartLine.product.name;
|
||||||
|
cartLineElement.querySelector('[data-attr=value]').textContent = CartManager.getNumberFormatter().format(cartLine.product.price);
|
||||||
|
cartLineElement.querySelector('[data-attr=quantity]').textContent = cartLine.quantity;
|
||||||
|
cartLineElement.addEventListener('click', () => {
|
||||||
|
this.removeProduct(cartLine.product);
|
||||||
|
})
|
||||||
|
return cartLineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total value of the cart by multiplying the product price with the quantity and setting it to the button.
|
||||||
|
*/
|
||||||
|
calculateCartValue() {
|
||||||
|
let cartValue = this.cartLines.reduce((acc, cartLine) => {
|
||||||
|
return acc + (cartLine.product.price * cartLine.quantity);
|
||||||
|
}, 0);
|
||||||
|
Element.getCartButton().querySelector('[data-total-value]').textContent = CartManager.getNumberFormatter().format(cartValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number formatter for the currency.
|
||||||
|
* @returns {Intl.NumberFormat}
|
||||||
|
*/
|
||||||
|
static getNumberFormatter() {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TabManager class manages the tabs in the settings page.
|
||||||
|
*/
|
||||||
|
class TabManager {
|
||||||
|
/**
|
||||||
|
* @type {boolean} If the settings tab is active or not
|
||||||
|
*/
|
||||||
|
static isSettingsTabActive = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
TabManager.isSettingsTabActive = false;
|
||||||
|
document.querySelector('[data-toggle-tab]').addEventListener('click', (e) => {
|
||||||
|
TabManager.toggleTab();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the active tab between settings and products.
|
||||||
|
*/
|
||||||
|
static toggleTab() {
|
||||||
|
if(!TabManager.isSettingsTabActive) {
|
||||||
|
TabManager.isSettingsTabActive = true;
|
||||||
|
this.switchTab('settings');
|
||||||
|
} else {
|
||||||
|
this.switchTab('products');
|
||||||
|
TabManager.isSettingsTabActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the active tab by adding/removing the d-none class.
|
||||||
|
* @param tab {string} The tab to switch to
|
||||||
|
*/
|
||||||
|
static switchTab(tab) {
|
||||||
|
if(tab !== 'settings') {
|
||||||
|
TabManager.isSettingsTabActive = false;
|
||||||
|
}
|
||||||
|
const tabs = document.querySelectorAll('[data-tab]');
|
||||||
|
tabs.forEach(e => {
|
||||||
|
e.classList.add('d-none');
|
||||||
|
});
|
||||||
|
const activeTab = document.querySelector(`[data-tab=${tab}]`);
|
||||||
|
activeTab.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeManager class manages the theme of the application.
|
||||||
|
*/
|
||||||
|
class ThemeManager {
|
||||||
|
constructor() {
|
||||||
|
this.setBootstrapTheme(localStorage.getItem('bootstrap-theme') || 'light');
|
||||||
|
this.registerThemeSwitchEvent();
|
||||||
|
this.showGitHubIconOnAlternateInstallation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the GitHub icon on alternate installation if the URL does not start with the specified string.
|
||||||
|
*/
|
||||||
|
showGitHubIconOnAlternateInstallation() {
|
||||||
|
if (!location.href.startsWith('https://cloudmaker97.github.io/DurstRechner')) {
|
||||||
|
Element.getGitHubReferenceLink().classList.remove('visually-hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register events for the theme switch button.
|
||||||
|
*/
|
||||||
|
registerThemeSwitchEvent() {
|
||||||
|
document.querySelectorAll('[data-toggle-bs-theme]').forEach(element => {
|
||||||
|
element.addEventListener('click', event => {
|
||||||
|
const theme = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
|
||||||
|
this.setBootstrapTheme(theme);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the bootstrap theme by setting the data-bs-theme attribute on the html element and saving it to local storage.
|
||||||
|
* @param theme {string} The theme to set (light or dark)
|
||||||
|
*/
|
||||||
|
setBootstrapTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
localStorage.setItem('bootstrap-theme', theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the CartHistoryManager class to manage the cart history.
|
||||||
|
*/
|
||||||
|
class CartHistoryManager {
|
||||||
|
static getTotal() {
|
||||||
|
return localStorage.getItem('cart-total-value') || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setTotal(value) {
|
||||||
|
localStorage.setItem('cart-total-value', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addToTotal(value) {
|
||||||
|
const total = parseFloat(CartHistoryManager.getTotal()) + value;
|
||||||
|
CartHistoryManager.setTotal(total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to initialize the application.
|
||||||
|
*/
|
||||||
|
const cartManager = new CartManager();
|
||||||
|
const productManager = new ProductManager();
|
||||||
|
const tabManager = new TabManager();
|
||||||
|
const themeManager = new ThemeManager();
|
||||||
|
const cartHistoryManager = new CartHistoryManager();
|
||||||
|
|
@ -1,605 +0,0 @@
|
||||||
/**
|
|
||||||
* Element class provides static methods to get various elements from the DOM.
|
|
||||||
*/
|
|
||||||
class Element {
|
|
||||||
/**
|
|
||||||
* Get the import/export textarea element
|
|
||||||
* @returns {HTMLTextAreaElement}
|
|
||||||
*/
|
|
||||||
static getImportExportTextarea() {
|
|
||||||
return document.querySelector('#input-export-import');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the product list element
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getProductListElement() {
|
|
||||||
return document.querySelector('.product-list');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the settings product list element
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getSettingListElement() {
|
|
||||||
return document.querySelector('.settings-product-list');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cart element
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getCartElement() {
|
|
||||||
return document.querySelector('.cart-items');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cart empty alert element
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getCartEmptyAlertElement() {
|
|
||||||
return document.querySelector('.alert.cart-empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the create new product button element
|
|
||||||
* @returns {HTMLButtonElement}
|
|
||||||
*/
|
|
||||||
static getCreateNewProductButton() {
|
|
||||||
return document.querySelector('#create-product');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cart button element
|
|
||||||
* @returns {HTMLButtonElement}
|
|
||||||
*/
|
|
||||||
static getCartButton() {
|
|
||||||
return document.querySelector('button.cart-value');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the navbar brand element
|
|
||||||
* @returns {Element}
|
|
||||||
*/
|
|
||||||
static getNavbarBrand() {
|
|
||||||
return document.querySelector('nav a.navbar-brand');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TemplateElement class provides static methods to get various templates from the DOM.
|
|
||||||
*/
|
|
||||||
class TemplateElement {
|
|
||||||
/**
|
|
||||||
* Get the product list element template
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getProductTemplate() {
|
|
||||||
const element = document.querySelector('[data-template=product]').cloneNode(true);
|
|
||||||
return this.removeTemplate(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cart line element template
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getCartLineTemplate() {
|
|
||||||
const element = document.querySelector('[data-template=product-line-item]').cloneNode(true);
|
|
||||||
return this.removeTemplate(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the settings product element template
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static getSettingsProductTemplate() {
|
|
||||||
const element = document.querySelector('[data-template=settings-product]').cloneNode(true);
|
|
||||||
return this.removeTemplate(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the template attribute from the element
|
|
||||||
* @param element {HTMLElement}
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
static removeTemplate(element) {
|
|
||||||
element.removeAttribute('data-template');
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Product class represents a product with an id, name, price, and image.
|
|
||||||
*/
|
|
||||||
class Product {
|
|
||||||
/**
|
|
||||||
* Product constructor
|
|
||||||
* @param name {string} Name of the product
|
|
||||||
* @param price {number} Price of the product
|
|
||||||
* @param image {string} Image of the product (source url or base64)
|
|
||||||
*/
|
|
||||||
constructor(name, price, image) {
|
|
||||||
this.id = Product.generateId();
|
|
||||||
this.name = name;
|
|
||||||
this.price = price;
|
|
||||||
this.image = image;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random id for the product
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
static generateId() {
|
|
||||||
return Math.floor(Math.random() * 1000000000) * Math.floor(Math.random() * 1000000000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the product html element by template and set the attributes by the product object
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
getProductElement() {
|
|
||||||
const productElement = TemplateElement.getProductTemplate();
|
|
||||||
productElement.setAttribute('data-id', this.id);
|
|
||||||
productElement.querySelector('[data-attr=name]').textContent = this.name;
|
|
||||||
productElement.querySelector('[data-attr=image]').src = this.image;
|
|
||||||
productElement.addEventListener('click', () => {
|
|
||||||
cartManager.addProduct(this);
|
|
||||||
})
|
|
||||||
return productElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the settings product html element by template and set the attributes by the product object
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
getSettingsProductElement() {
|
|
||||||
const productElement = TemplateElement.getSettingsProductTemplate();
|
|
||||||
productElement.setAttribute('data-id', this.id);
|
|
||||||
productElement.textContent = this.name;
|
|
||||||
productElement.addEventListener('click', () => {
|
|
||||||
productManager.removeProduct(this);
|
|
||||||
productManager.setExportFieldJsonValue();
|
|
||||||
productElement.remove();
|
|
||||||
})
|
|
||||||
return productElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CartLine class represents a line in the cart with a product and its quantity.
|
|
||||||
*/
|
|
||||||
class CartLine {
|
|
||||||
/**
|
|
||||||
* CartLine constructor
|
|
||||||
* @param product {Product}
|
|
||||||
* @param quantity {number}
|
|
||||||
*/
|
|
||||||
constructor(product, quantity) {
|
|
||||||
this.product = product;
|
|
||||||
this.quantity = quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64Image class provides static methods to convert an image URL or file to a base64 string.
|
|
||||||
*/
|
|
||||||
class Base64Image {
|
|
||||||
/**
|
|
||||||
* Convert an image URL to a base64 string
|
|
||||||
* @param url
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
static fromImageUrl(url) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
const dataUrl = canvas.toDataURL();
|
|
||||||
resolve(dataUrl);
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize an image from a base64 string to a smaller size
|
|
||||||
* @param base64
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
static imageResize(base64) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 250;
|
|
||||||
canvas.height = 250;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
||||||
const dataUrl = canvas.toDataURL();
|
|
||||||
resolve(dataUrl);
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = base64;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a file to a base64 string
|
|
||||||
* @param file
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
static fromFile(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
resolve(reader.result);
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProductManager class manages the product list, including loading from JSON, adding, and removing products.
|
|
||||||
*/
|
|
||||||
class ProductManager {
|
|
||||||
constructor() {
|
|
||||||
this.loadFromJson(localStorage.getItem('products') ?? [])
|
|
||||||
this.setExportFieldJsonValue();
|
|
||||||
this.registerSettingsFormEvents();
|
|
||||||
this.registerImportFormEvents();
|
|
||||||
this.registerNavbarBrandToProductEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the event for the navbar brand to switch to the products tab when clicked.
|
|
||||||
*/
|
|
||||||
registerNavbarBrandToProductEvent() {
|
|
||||||
Element.getNavbarBrand().addEventListener('click', () => {
|
|
||||||
TabManager.switchTab('products');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register events for the import/export textarea.
|
|
||||||
*/
|
|
||||||
registerImportFormEvents() {
|
|
||||||
Element.getImportExportTextarea().addEventListener('input', (e) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('products', e.target.value);
|
|
||||||
window.location = window.location;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the JSON value of the export field to the current products.
|
|
||||||
*/
|
|
||||||
setExportFieldJsonValue() {
|
|
||||||
Element.getImportExportTextarea().textContent = JSON.stringify(this.products);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load products from JSON string and parse it into Product objects, then render the product list.
|
|
||||||
* @param json
|
|
||||||
*/
|
|
||||||
loadFromJson(json) {
|
|
||||||
let parse = [];
|
|
||||||
try {
|
|
||||||
parse = JSON.parse(json);
|
|
||||||
} catch (e) {}
|
|
||||||
this.products = parse.map(e => new Product(e.name, e.price, e.image));
|
|
||||||
this.renderProductList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register events for the create new product button.
|
|
||||||
*/
|
|
||||||
registerSettingsFormEvents() {
|
|
||||||
Element.getCreateNewProductButton().addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const name = document.querySelector('#product-name').value;
|
|
||||||
const price = document.querySelector('#product-price').value;
|
|
||||||
const image = document.querySelector('#product-image').files[0];
|
|
||||||
|
|
||||||
// Validate price
|
|
||||||
if (name.length === 0) {
|
|
||||||
alert('Name muss ausgefüllt sein');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn image into base64 or use placeholder image
|
|
||||||
if (image) {
|
|
||||||
const imageSrcWithBase64 = Base64Image.fromFile(image);
|
|
||||||
imageSrcWithBase64.then(async imageSrcWithBase64 => {
|
|
||||||
productManager.addProduct(new Product(name, price, await Base64Image.imageResize(imageSrcWithBase64)));
|
|
||||||
ProductManager.resetProductSettingsForm();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let image1 = await Base64Image.imageResize(await Base64Image.fromImageUrl(`https://placehold.co/250x250?text=${name}`));
|
|
||||||
productManager.addProduct(new Product(name, price, image1));
|
|
||||||
ProductManager.resetProductSettingsForm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the product settings form by clearing all input fields.
|
|
||||||
*/
|
|
||||||
static resetProductSettingsForm() {
|
|
||||||
document.querySelectorAll('form input:not([type=submit])').forEach(inputField => inputField.value = '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the product list by clearing the existing elements and appending new ones.
|
|
||||||
*/
|
|
||||||
renderProductList() {
|
|
||||||
// Clear the product list element before rendering except the template
|
|
||||||
Element.getProductListElement().querySelectorAll('[data-id]').forEach(e => {
|
|
||||||
if (e.getAttribute('data-template') === null) {
|
|
||||||
e.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.products.forEach(e => {
|
|
||||||
Element.getProductListElement().appendChild(e.getProductElement());
|
|
||||||
Element.getSettingListElement().appendChild(e.getSettingsProductElement());
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new product to the list and save it to local storage.
|
|
||||||
* @param product {Product}
|
|
||||||
*/
|
|
||||||
addProduct(product) {
|
|
||||||
this.products.push(product);
|
|
||||||
localStorage.setItem('products', JSON.stringify(this.products));
|
|
||||||
Element.getProductListElement().appendChild(product.getProductElement());
|
|
||||||
Element.getSettingListElement().appendChild(product.getSettingsProductElement());
|
|
||||||
this.setExportFieldJsonValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a product from the list and local storage.
|
|
||||||
* @param product {Product}
|
|
||||||
*/
|
|
||||||
removeProduct(product) {
|
|
||||||
this.products = this.products.filter(e => e.id !== product.id);
|
|
||||||
const productElement = Element.getProductListElement().querySelector(`[data-id="${product.id}"]`);
|
|
||||||
if (productElement) {
|
|
||||||
productElement.remove();
|
|
||||||
}
|
|
||||||
localStorage.setItem('products', JSON.stringify(this.products));
|
|
||||||
this.setExportFieldJsonValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CartManager class manages the cart, including adding, removing products, and rendering the cart.
|
|
||||||
*/
|
|
||||||
class CartManager {
|
|
||||||
constructor() {
|
|
||||||
this.cartLines = [];
|
|
||||||
this.registerCartResetEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the cart reset event to clear the cart when the button is clicked.
|
|
||||||
*/
|
|
||||||
registerCartResetEvent() {
|
|
||||||
Element.getCartButton().addEventListener('click', () => {
|
|
||||||
CartHistoryManager.addToTotal(this.cartLines.reduce((acc, cartLine) => {
|
|
||||||
return acc + (cartLine.product.price * cartLine.quantity);
|
|
||||||
}, 0));
|
|
||||||
this.cartLines = [];
|
|
||||||
this.renderCart();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a product to the cart. If the product already exists, increase the quantity.
|
|
||||||
* @param product {Product}
|
|
||||||
*/
|
|
||||||
addProduct(product) {
|
|
||||||
const cartLine = this.cartLines.find(e => e.product.id === product.id);
|
|
||||||
if (cartLine) {
|
|
||||||
cartLine.quantity++;
|
|
||||||
} else {
|
|
||||||
this.cartLines.push(new CartLine(product, 1));
|
|
||||||
}
|
|
||||||
this.renderCart();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a product from the cart. If the quantity is 0, remove the product from the cart.
|
|
||||||
* @param product {Product}
|
|
||||||
*/
|
|
||||||
removeProduct(product) {
|
|
||||||
const cartLine = this.cartLines.find(e => e.product.id === product.id);
|
|
||||||
if (cartLine) {
|
|
||||||
cartLine.quantity--;
|
|
||||||
if (cartLine.quantity <= 0) {
|
|
||||||
this.cartLines = this.cartLines.filter(e => e.product.id !== product.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.renderCart();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the cart by clearing the existing elements and appending new ones.
|
|
||||||
*/
|
|
||||||
renderCart() {
|
|
||||||
// Clear the cart element before rendering except the template
|
|
||||||
Element.getCartElement().querySelectorAll('[data-id]').forEach(e => {
|
|
||||||
if (e.getAttribute('data-template') === null) {
|
|
||||||
e.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if(this.cartLines.length === 0) {
|
|
||||||
Element.getCartEmptyAlertElement().classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
Element.getCartEmptyAlertElement().classList.add('d-none');
|
|
||||||
}
|
|
||||||
this.calculateCartValue();
|
|
||||||
|
|
||||||
// Render each cart line
|
|
||||||
this.cartLines.forEach(cartLine => {
|
|
||||||
const cartLineElement = this.getCartLineElement(cartLine);
|
|
||||||
Element.getCartElement().appendChild(cartLineElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cart line html element and set the attributes by the product object
|
|
||||||
* @param cartLine {CartLine}
|
|
||||||
* @returns {*}
|
|
||||||
*/
|
|
||||||
getCartLineElement(cartLine) {
|
|
||||||
const cartLineElement = TemplateElement.getCartLineTemplate();
|
|
||||||
cartLineElement.setAttribute('data-id', cartLine.product.id);
|
|
||||||
cartLineElement.querySelector('[data-attr=name]').textContent = cartLine.product.name;
|
|
||||||
cartLineElement.querySelector('[data-attr=value]').textContent = CartManager.getNumberFormatter().format(cartLine.product.price);
|
|
||||||
cartLineElement.querySelector('[data-attr=quantity]').textContent = cartLine.quantity;
|
|
||||||
cartLineElement.addEventListener('click', () => {
|
|
||||||
this.removeProduct(cartLine.product);
|
|
||||||
})
|
|
||||||
return cartLineElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the total value of the cart by multiplying the product price with the quantity and setting it to the button.
|
|
||||||
*/
|
|
||||||
calculateCartValue() {
|
|
||||||
let cartValue = this.cartLines.reduce((acc, cartLine) => {
|
|
||||||
return acc + (cartLine.product.price * cartLine.quantity);
|
|
||||||
}, 0);
|
|
||||||
Element.getCartButton().querySelector('[data-total-value]').textContent = CartManager.getNumberFormatter().format(cartValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number formatter for the currency.
|
|
||||||
* @returns {Intl.NumberFormat}
|
|
||||||
*/
|
|
||||||
static getNumberFormatter() {
|
|
||||||
return new Intl.NumberFormat('de-DE', {
|
|
||||||
currency: 'EUR',
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TabManager class manages the tabs in the settings page.
|
|
||||||
*/
|
|
||||||
class TabManager {
|
|
||||||
/**
|
|
||||||
* @type {boolean} If the settings tab is active or not
|
|
||||||
*/
|
|
||||||
static isSettingsTabActive = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
TabManager.isSettingsTabActive = false;
|
|
||||||
document.querySelector('[data-toggle-tab]').addEventListener('click', (e) => {
|
|
||||||
TabManager.toggleTab();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the active tab between settings and products.
|
|
||||||
*/
|
|
||||||
static toggleTab() {
|
|
||||||
if(!TabManager.isSettingsTabActive) {
|
|
||||||
TabManager.isSettingsTabActive = true;
|
|
||||||
this.switchTab('settings');
|
|
||||||
} else {
|
|
||||||
this.switchTab('products');
|
|
||||||
TabManager.isSettingsTabActive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch the active tab by adding/removing the d-none class.
|
|
||||||
* @param tab {string} The tab to switch to
|
|
||||||
*/
|
|
||||||
static switchTab(tab) {
|
|
||||||
if(tab !== 'settings') {
|
|
||||||
TabManager.isSettingsTabActive = false;
|
|
||||||
}
|
|
||||||
const tabs = document.querySelectorAll('[data-tab]');
|
|
||||||
tabs.forEach(e => {
|
|
||||||
e.classList.add('d-none');
|
|
||||||
});
|
|
||||||
const activeTab = document.querySelector(`[data-tab=${tab}]`);
|
|
||||||
activeTab.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ThemeManager class manages the theme of the application.
|
|
||||||
*/
|
|
||||||
class ThemeManager {
|
|
||||||
constructor() {
|
|
||||||
this.setBootstrapTheme(localStorage.getItem('bootstrap-theme') || 'light');
|
|
||||||
this.registerThemeSwitchEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register events for the theme switch button.
|
|
||||||
*/
|
|
||||||
registerThemeSwitchEvent() {
|
|
||||||
document.querySelectorAll('[data-toggle-bs-theme]').forEach(element => {
|
|
||||||
element.addEventListener('click', event => {
|
|
||||||
const theme = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
|
|
||||||
this.setBootstrapTheme(theme);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the bootstrap theme by setting the data-bs-theme attribute on the html element and saving it to local storage.
|
|
||||||
* @param theme {string} The theme to set (light or dark)
|
|
||||||
*/
|
|
||||||
setBootstrapTheme(theme) {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
||||||
localStorage.setItem('bootstrap-theme', theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define the CartHistoryManager class to manage the cart history.
|
|
||||||
*/
|
|
||||||
class CartHistoryManager {
|
|
||||||
static getTotal() {
|
|
||||||
return localStorage.getItem('cart-total-value') || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static setTotal(value) {
|
|
||||||
localStorage.setItem('cart-total-value', value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static addToTotal(value) {
|
|
||||||
const total = parseFloat(CartHistoryManager.getTotal()) + value;
|
|
||||||
CartHistoryManager.setTotal(total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to initialize the application.
|
|
||||||
*/
|
|
||||||
const cartManager = new CartManager();
|
|
||||||
const productManager = new ProductManager();
|
|
||||||
const tabManager = new TabManager();
|
|
||||||
const themeManager = new ThemeManager();
|
|
||||||
const cartHistoryManager = new CartHistoryManager();
|
|
||||||
|
|
@ -9,7 +9,6 @@ const FILES_TO_CACHE = [
|
||||||
`${BASE_PATH}/assets/script/service-worker.js`,
|
`${BASE_PATH}/assets/script/service-worker.js`,
|
||||||
`${BASE_PATH}/assets/style/stylesheet.css`,
|
`${BASE_PATH}/assets/style/stylesheet.css`,
|
||||||
`${BASE_PATH}/assets/script/all.js`,
|
`${BASE_PATH}/assets/script/all.js`,
|
||||||
`${BASE_PATH}/assets/script/calculator.js`,
|
|
||||||
`${BASE_PATH}/node_modules/bootstrap/dist/css/bootstrap.min.css`,
|
`${BASE_PATH}/node_modules/bootstrap/dist/css/bootstrap.min.css`,
|
||||||
`${BASE_PATH}/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js`,
|
`${BASE_PATH}/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js`,
|
||||||
`${BASE_PATH}/node_modules/bootstrap-icons/font/bootstrap-icons.min.css`,
|
`${BASE_PATH}/node_modules/bootstrap-icons/font/bootstrap-icons.min.css`,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,40 @@
|
||||||
.line-item-details {
|
/**
|
||||||
|
* Here are some variables that are used in the whole application.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--currency-symbol: '€';
|
||||||
|
--separator: '|';
|
||||||
|
--price-color-light-mode: var(--bs-primary-rgb);
|
||||||
|
--price-color-dark-mode: var(--bs-info-rgb);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Let the user never select the text in the product list, cart items, alert and navbar brand.
|
||||||
|
* This is used to prevent the user from selecting the text in the product list and cart items. (Usability)
|
||||||
|
*/
|
||||||
|
.cart-items, .product-list, .alert, .navbar-brand, .settings-product-list {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Make product boxes, cart items and settings product list clickable
|
||||||
|
* This is generally used for product list and cart items
|
||||||
|
*/
|
||||||
|
.product-box, .cart-items, .settings-product-list, .navbar-brand {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This is a dummy element, that can be copied via JavaScript
|
||||||
|
* and will be filled dynamically with some data.
|
||||||
|
*/
|
||||||
|
[data-template] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This is the cart with its line items
|
||||||
|
*/
|
||||||
|
.cart {
|
||||||
|
.line-item-details {
|
||||||
.amount {
|
.amount {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 1.5em;
|
min-width: 1.5em;
|
||||||
|
|
@ -7,33 +43,68 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 3em;
|
min-width: 3em;
|
||||||
}
|
}
|
||||||
|
.name {
|
||||||
|
margin-left: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Product list contains all available products,
|
||||||
|
* which are displayed in a grid layout. The grid scaling is done by bootstrap.
|
||||||
|
*/
|
||||||
.product-list {
|
.product-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 4fr));
|
||||||
|
gap: .4em;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product box for product list
|
||||||
|
* @param {string} data-attr - product attribute
|
||||||
|
*/
|
||||||
|
.product-box {
|
||||||
|
/* Product box should be clickable */
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/* Images shouldn't drag in user interface */
|
||||||
|
img {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
-khtml-user-drag: none;
|
||||||
|
user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break the words on the card body */
|
||||||
|
.card-body span {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
/* Slightly bold font for the price attribute */
|
||||||
|
.product-box small[data-attr=price] {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/* When using dark mode, use info color for price */
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
.product-box small[data-attr=price] {
|
||||||
|
color: rgba(var(--price-color-dark-mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* When using light mode, use primary color for price */
|
||||||
|
[data-bs-theme="light"] {
|
||||||
|
.product-box small[data-attr=price] {
|
||||||
|
color: rgba(var(--price-color-light-mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Insert currency symbol after each value */
|
||||||
.currency-value::after {
|
.currency-value::after {
|
||||||
content: ' €';
|
content: ' ' var(--currency-symbol);
|
||||||
}
|
}
|
||||||
|
/* Insert separator */
|
||||||
|
.separator::after {
|
||||||
|
content: ' ' var(--separator) ' ';
|
||||||
|
}
|
||||||
|
/* Insert times x symbol after an amount */
|
||||||
.quantity-value::after {
|
.quantity-value::after {
|
||||||
content: 'x ';
|
content: 'x ';
|
||||||
}
|
}
|
||||||
.name-value {
|
|
||||||
margin-left: 1em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
[data-template] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.cart-items, .product-list, .alert, .navbar-brand, .settings-product-list {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
.product-box, .cart-items, .settings-product-list {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
79
index.html
|
|
@ -14,19 +14,29 @@
|
||||||
<title>Durstrechner</title>
|
<title>Durstrechner</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column vh-100">
|
<body class="d-flex flex-column vh-100">
|
||||||
<nav class="navbar navbar-expand-lg border-bottom mb-3">
|
<nav class="navbar navbar-expand-lg border-bottom mb-3">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#">Der Durstrechner</a>
|
<span class="navbar-brand d-flex gap-2" href="#">
|
||||||
|
<span>Der Durstrechner</span>
|
||||||
|
<a href="https://github.com/cloudmaker97/DurstRechner" class="text-white visually-hidden" title="Projekt auf GitHub" data-github-ref style="width: 20px; height: 20px;">
|
||||||
|
<i class="bi bi-github"></i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
<form class="d-flex" role="search" method="dialog">
|
<form class="d-flex" role="search" method="dialog">
|
||||||
<button class="btn" data-toggle-bs-theme><i class="bi bi-lightbulb-fill"></i> <span class="d-none d-md-inline-block">Tag / Nacht</span></button>
|
<button class="btn" data-toggle-bs-theme>
|
||||||
<button class="btn" data-toggle-tab><i class="bi bi-gear"></i> <span class="d-none d-md-inline-block">Einstellungen</span></button>
|
<i class="bi bi-lightbulb-fill"></i> <span class="d-none d-md-inline-block">Tag / Nacht</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-toggle-tab>
|
||||||
|
<i class="bi bi-gear"></i> <span class="d-none d-md-inline-block">Einstellungen</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container-fluid flex-grow-1">
|
<main class="container-fluid flex-grow-1">
|
||||||
<div data-tab="products" class="row">
|
<div data-tab="products" class="row">
|
||||||
<div class="col-12 col-md-4 products">
|
<aside class="col-12 col-md-4 cart">
|
||||||
<li class="list-group-item d-flex justify-content-between" data-template="product-line-item">
|
<li class="list-group-item d-flex justify-content-between" data-template="product-line-item">
|
||||||
<span class="line-item-details">
|
<span class="line-item-details">
|
||||||
<span class="amount quantity-value" data-attr="quantity">5</span>
|
<span class="amount quantity-value" data-attr="quantity">5</span>
|
||||||
|
|
@ -41,14 +51,18 @@
|
||||||
<button class="btn btn-secondary btn-lg w-100 cart-value mb-3">
|
<button class="btn btn-secondary btn-lg w-100 cart-value mb-3">
|
||||||
<span class="currency-value" data-total-value>0,00</span>
|
<span class="currency-value" data-total-value>0,00</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</aside>
|
||||||
<div class="col-12 col-md-8">
|
<div class="col-12 col-md-8">
|
||||||
<div class="row row-cols-2 row-cols-md-5 row-gap-3 product-list h-100 overflow-auto">
|
<div class="product-list">
|
||||||
<div class="col product-box" data-template="product" data-id="0">
|
<div class="col product-box" data-template="product" data-id="0">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img data-attr="image" src="https://placehold.co/250x250" alt="Produktbild" class="card-img-top" loading="eager">
|
<img data-attr="image"
|
||||||
|
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg=="
|
||||||
|
alt="Produktbild" class="card-img-top" loading="eager">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span data-attr="name">Wasser mit Kohlensäure</span>
|
<small data-attr="price" class="currency-value">0,00</small>
|
||||||
|
<i class="separator"></i>
|
||||||
|
<small data-attr="name">Wasser mit Kohlensäure</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,7 +70,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-tab="settings" class="row d-none">
|
<div data-tab="settings" class="row d-none">
|
||||||
<div class="col-12 col-md-6">
|
<aside class="col-12 col-md-6">
|
||||||
<form class="card mb-2" method="dialog" id="new-product">
|
<form class="card mb-2" method="dialog" id="new-product">
|
||||||
<h5 class="card-header">Neues Produkt</h5>
|
<h5 class="card-header">Neues Produkt</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -82,13 +96,44 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">Export / Import</h5>
|
<h5 class="card-header">Export / Import</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<label class="w-100">
|
<details class="mb-2">
|
||||||
Daten importieren oder exportieren (Kopieren und Einfügen)
|
<summary>Exportieren und Importieren</summary>
|
||||||
|
<p>
|
||||||
|
Die Daten können im JSON-Format exportiert und importiert werden.
|
||||||
|
Um Speicherplatz zu sparen, sollte das Bild im Format 1:1 vorliegen.
|
||||||
|
Ideal ist eine Bildgröße von 250x250 Pixel. Wenn ein Bild über eine URL bereitgestellt wird,
|
||||||
|
lädt die Software das Bild herunter und speichert es in der JSON-Datei als Base64-kodierte
|
||||||
|
Version. Die ursprüngliche URL wird dabei nicht mehr gespeichert.
|
||||||
|
</p>
|
||||||
|
<label class="w-100 form-label">
|
||||||
|
<b>Text zum Kopieren und Einfügen:</b>
|
||||||
<textarea class="form-control mt-1" id="input-export-import" spellcheck="false"></textarea>
|
<textarea class="form-control mt-1" id="input-export-import" spellcheck="false"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Testdaten installieren</summary>
|
||||||
|
<p>
|
||||||
|
Über den Knopf "Testdaten einspielen" können Testdaten in die Software importiert werden
|
||||||
|
oder auch angezeigt werden. Die Daten können nach eigenen Belieben bearbeitet werden. Die
|
||||||
|
Testdaten könnten einen kleinen Augenblick laden, da sie erst heruntergeladen werden müssen.
|
||||||
|
</p>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-action="import-testdata">
|
||||||
|
Testdaten einspielen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||||
|
<span class="visually-hidden">Testdaten einspielen</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="./assets/example/example.json">Import-Datei anzeigen</a></li>
|
||||||
|
<li><a class="dropdown-item" href="./assets/example/example_urls.json">Import-Datei mit URLs anzeigen</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
<div class="col-12 col-md-6 mt-2 mt-md-0">
|
<div class="col-12 col-md-6 mt-2 mt-md-0">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">Produkte entfernen</h5>
|
<h5 class="card-header">Produkte entfernen</h5>
|
||||||
|
|
@ -101,8 +146,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/script/all.js" type="module"></script>
|
<script src="assets/script/all.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
"name": "durst-rechner",
|
"name": "durst-rechner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {},
|
"scripts": {},
|
||||||
"keywords": ["calculator", "feuerwehr", "firefighter", "pos", "thirst"],
|
"keywords": [
|
||||||
|
"calculator",
|
||||||
|
"feuerwehr",
|
||||||
|
"firefighter",
|
||||||
|
"pos",
|
||||||
|
"thirst"
|
||||||
|
],
|
||||||
"author": "Dennis Heinrich",
|
"author": "Dennis Heinrich",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||