Compare commits

..

8 commits

50 changed files with 1036 additions and 736 deletions

BIN
.github/screenshots/header.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

BIN
.github/screenshots/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
.github/screenshots/mobile_dark_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

BIN
.github/screenshots/mobile_dark_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
.github/screenshots/mobile_light_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
.github/screenshots/mobile_light_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

BIN
.github/screenshots/wide_dark_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

BIN
.github/screenshots/wide_dark_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
.github/screenshots/wide_dark_3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

BIN
.github/screenshots/wide_light_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
.github/screenshots/wide_light_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
.github/screenshots/wide_light_3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

View file

@ -1,3 +1,7 @@
![preview.png](.github/screenshots/header.png)
> Weitere Screenshots sind im Ordner `.github/screenshots` zu finden.
# Der Durstrechner
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
genügt).
![preview.png](.github/screenshots/preview.png)
> Weitere Screenshots sind im Ordner `.github/screenshots` zu finden.
## Anleitung und Hinweise
@ -24,6 +25,5 @@ genügt).
pnpm install
# Startet den Entwicklungsserver
npx http-server -o .
npx http-server -o .
```

View 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"
}
]

View 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"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -3,32 +3,37 @@
"short_name": "Durstrechner",
"description": "Der Durstrechner ist ein einfaches Tool zur Berechnung von Verkaufspreisen bei Veranstaltungen.",
"start_url": "../",
"scope": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196f3",
"screenshots": [
{
"src": "pwa/dark.png",
"type": "image/png",
"sizes": "1064x2008"
},
{
"src": "pwa/light.png",
"type": "image/png",
"sizes": "1064x2008"
},
{
"src": "pwa/wide_dark.png",
"src": "pwa/logo.png",
"type": "image/png",
"sizes": "1024x1024",
"form_factor": "wide",
"sizes": "3604x2008"
"label": "Durstrechner Logo"
},
{
"src": "pwa/wide_light.png",
"src": "pwa/wide.png",
"type": "image/png",
"sizes": "2988x2014",
"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": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

BIN
assets/pwa/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/pwa/tall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/pwa/wide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

View file

@ -1,10 +1,665 @@
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) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./assets/script/service-worker.js')
.then(reg => console.log('Service Worker installiert', reg))
.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();

View file

@ -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();

View file

@ -9,7 +9,6 @@ const FILES_TO_CACHE = [
`${BASE_PATH}/assets/script/service-worker.js`,
`${BASE_PATH}/assets/style/stylesheet.css`,
`${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/js/bootstrap.bundle.min.js`,
`${BASE_PATH}/node_modules/bootstrap-icons/font/bootstrap-icons.min.css`,

View file

@ -1,39 +1,110 @@
.line-item-details {
.amount {
display: inline-block;
min-width: 1.5em;
}
.price {
display: inline-block;
min-width: 3em;
}
}
.product-list {
padding-bottom: 1em;
}
.currency-value::after {
content: ' €';
}
.quantity-value::after {
content: 'x ';
}
.name-value {
margin-left: 1em;
font-weight: bold;
}
[data-template] {
display: none !important;
/**
* 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;
}
img {
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
.product-box, .cart-items, .settings-product-list {
/**
* 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 {
display: inline-block;
min-width: 1.5em;
}
.price {
display: inline-block;
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 4fr));
gap: .4em;
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 {
content: ' ' var(--currency-symbol);
}
/* Insert separator */
.separator::after {
content: ' ' var(--separator) ' ';
}
/* Insert times x symbol after an amount */
.quantity-value::after {
content: 'x ';
}

View file

@ -14,95 +14,140 @@
<title>Durstrechner</title>
</head>
<body class="d-flex flex-column vh-100">
<nav class="navbar navbar-expand-lg border-bottom mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Der Durstrechner</a>
<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-tab><i class="bi bi-gear"></i> <span class="d-none d-md-inline-block">Einstellungen</span></button>
</form>
</div>
</nav>
<nav class="navbar navbar-expand-lg border-bottom mb-3">
<div class="container-fluid">
<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>
<main class="container-fluid flex-grow-1">
<div data-tab="products" class="row">
<div class="col-12 col-md-4 products">
<li class="list-group-item d-flex justify-content-between" data-template="product-line-item">
<span class="line-item-details">
<span class="amount quantity-value" data-attr="quantity">5</span>
<span class="price currency-value" data-attr="value">2,50</span>
<span class="name name-value" data-attr="name">Wasser mit Kohlensäure</span>
</span>
<i class="bi bi-dash-circle"></i>
</li>
<ul class="list-group cart-items"></ul>
<div class="alert alert-info cart-empty">Keine Produkte ausgewählt</div>
<hr>
<button class="btn btn-secondary btn-lg w-100 cart-value mb-3">
<span class="currency-value" data-total-value>0,00</span>
</button>
</div>
<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="col product-box" data-template="product" data-id="0">
<div class="card">
<img data-attr="image" src="https://placehold.co/250x250" alt="Produktbild" class="card-img-top" loading="eager">
<div class="card-body">
<span data-attr="name">Wasser mit Kohlensäure</span>
</div>
<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-tab>
<i class="bi bi-gear"></i> <span class="d-none d-md-inline-block">Einstellungen</span>
</button>
</form>
</div>
</nav>
<main class="container-fluid flex-grow-1">
<div data-tab="products" class="row">
<aside class="col-12 col-md-4 cart">
<li class="list-group-item d-flex justify-content-between" data-template="product-line-item">
<span class="line-item-details">
<span class="amount quantity-value" data-attr="quantity">5</span>
<span class="price currency-value" data-attr="value">2,50</span>
<span class="name name-value" data-attr="name">Wasser mit Kohlensäure</span>
</span>
<i class="bi bi-dash-circle"></i>
</li>
<ul class="list-group cart-items"></ul>
<div class="alert alert-info cart-empty">Keine Produkte ausgewählt</div>
<hr>
<button class="btn btn-secondary btn-lg w-100 cart-value mb-3">
<span class="currency-value" data-total-value>0,00</span>
</button>
</aside>
<div class="col-12 col-md-8">
<div class="product-list">
<div class="col product-box" data-template="product" data-id="0">
<div class="card">
<img data-attr="image"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+BCQAHBQICJmhD1AAAAABJRU5ErkJggg=="
alt="Produktbild" class="card-img-top" loading="eager">
<div class="card-body">
<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 data-tab="settings" class="row d-none">
<div class="col-12 col-md-6">
<form class="card mb-2" method="dialog" id="new-product">
<h5 class="card-header">Neues Produkt</h5>
<div class="card-body">
<div class="mb-2">
<label for="product-name" class="form-label">Name des Produkts</label>
<input type="text" class="form-control" placeholder="Tolles Produkt" id="product-name" required>
</div>
<div class="mb-2">
<label for="product-price" class="form-label">Preis</label>
<div class="input-group">
<span class="input-group-text" id="basic-addon1"></span>
<input type="number" class="form-control" step="0.01" value="" id="product-price" required>
</div>
</div>
<div class="mb-2">
<label for="product-image" class="form-label">Bild auswählen</label>
<input type="file" class="form-control" placeholder="Tolles Produkt" id="product-image">
</div>
<input type="submit" class="btn btn-success" value="Speichern" id="create-product">
</div>
<div data-tab="settings" class="row d-none">
<aside class="col-12 col-md-6">
<form class="card mb-2" method="dialog" id="new-product">
<h5 class="card-header">Neues Produkt</h5>
<div class="card-body">
<div class="mb-2">
<label for="product-name" class="form-label">Name des Produkts</label>
<input type="text" class="form-control" placeholder="Tolles Produkt" id="product-name" required>
</div>
</form>
<div class="mb-2">
<label for="product-price" class="form-label">Preis</label>
<div class="input-group">
<span class="input-group-text" id="basic-addon1"></span>
<input type="number" class="form-control" step="0.01" value="" id="product-price" required>
</div>
</div>
<div class="mb-2">
<label for="product-image" class="form-label">Bild auswählen</label>
<input type="file" class="form-control" placeholder="Tolles Produkt" id="product-image">
</div>
<input type="submit" class="btn btn-success" value="Speichern" id="create-product">
</div>
</form>
<div class="card">
<h5 class="card-header">Export / Import</h5>
<div class="card-body">
<label class="w-100">
Daten importieren oder exportieren (Kopieren und Einfügen)
<div class="card">
<h5 class="card-header">Export / Import</h5>
<div class="card-body">
<details class="mb-2">
<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>
</label>
</div>
</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 class="col-12 col-md-6 mt-2 mt-md-0">
<div class="card">
<h5 class="card-header">Produkte entfernen</h5>
<div class="card-body">
<p class="mb-2">Hier können Produkte entfernt werden, die nicht mehr benötigt werden.</p>
<li class="list-group-item" data-template="settings-product">Test</li>
<ul class="list-group settings-product-list">
</ul>
</div>
</aside>
<div class="col-12 col-md-6 mt-2 mt-md-0">
<div class="card">
<h5 class="card-header">Produkte entfernen</h5>
<div class="card-body">
<p class="mb-2">Hier können Produkte entfernt werden, die nicht mehr benötigt werden.</p>
<li class="list-group-item" data-template="settings-product">Test</li>
<ul class="list-group settings-product-list">
</ul>
</div>
</div>
</div>
</main>
</div>
</main>
<script src="assets/script/all.js" type="module"></script>
<script src="assets/script/all.js" type="module"></script>
</body>
</html>

View file

@ -2,7 +2,13 @@
"name": "durst-rechner",
"version": "1.0.0",
"scripts": {},
"keywords": ["calculator", "feuerwehr", "firefighter", "pos", "thirst"],
"keywords": [
"calculator",
"feuerwehr",
"firefighter",
"pos",
"thirst"
],
"author": "Dennis Heinrich",
"license": "BSD-3-Clause",
"dependencies": {