commit fc93e5097b74f6db31e653eb76d7be7d6914b681 Author: Lorenz Stechauner <lorenz.stechauner@necronda.net> Date: Sat May 3 15:50:00 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..541957b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +credentials.* +!*.sample.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..de7b20f --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ + +# Elwig Access Frontend + +The web frontend for accessing Elwig online. + +https://access.elwig.at/ diff --git a/www/.php/credentials.sample.inc b/www/.php/credentials.sample.inc new file mode 100644 index 0000000..d91308d --- /dev/null +++ b/www/.php/credentials.sample.inc @@ -0,0 +1,18 @@ +<?php +global $CLIENT_ACCESS; +global $COMBINED_ACCESS; + +$CLIENT_ACCESS = [ + 'WGX' => [ + 'name' => 'Winzergenossenschaft Ort', + 'short' => 'Ort', + 'api' => 'https://example.com/elwig/api/v1', + ], +]; + +$COMBINED_ACCESS = [ + 'HOLDING' => [ + 'name' => 'Name', + 'clients' => ['WGX'], + ], +]; diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..985b599 Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/index.php b/www/index.php new file mode 100644 index 0000000..1176757 --- /dev/null +++ b/www/index.php @@ -0,0 +1,100 @@ +<?php +require ".php/credentials.inc"; +global $CLIENT_ACCESS; +global $COMBINED_ACCESS; + +$info = explode('/', $_SERVER['PATH_INFO']); +$client = null; +foreach ($CLIENT_ACCESS as $name => $data) { + if ($name === $info[1] && (sizeof($info) === 2 || $info[2] === '')) { + $client = $data; + $client['id'] = $name; + } +} +foreach ($COMBINED_ACCESS as $name => $data) { + if ($name === $info[1] && (sizeof($info) === 2 || $info[2] === '')) { + $client = $data; + $client['id'] = $name; + } +} +if ($client === null) { + header('Status: 200'); + header('Content-Length: 0'); + exit(); +} else if ($_SERVER['PATH_INFO'] !== "/$client[id]/") { + header('Status: 308'); + header("Location: /$client[id]/"); + exit(); +} + +if ($client['api']) { + $data = "window.ELWIG_API = \"$client[api]\";"; +} else { + $data = "window.CLIENTS = {"; + $first = true; + foreach ($client['clients'] as $id) { + $c = $CLIENT_ACCESS[$id]; + if (!$first) $data .= ", "; + $data .= "\"$id\": {\"name\": \"$c[name]\", \"short\": \"$c[short]\", \"api\": \"$c[api]\"}"; + $first = false; + } + $data .= "};"; +} + +header('Content-Type: application/xhtml+xml; charset=UTF-8'); +echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; ?> +<html xmlns="http://www.w3.org/1999/xhtml" lang="de"> +<head> + <meta charset="UTF-8"/> + <title><?php echo $client['name']; ?> - Elwig</title> + <link rel="icon" href="/favicon.ico" sizes="16x16 20x20 24x24 30x30 32x32 36x36 40x40 48x48 60x60 64x64 72x72 80x80 96x96 128x128 256x256"/> + <link rel="stylesheet" href="/res/style.css"/> + <meta name="viewport" content="width=device-width,initial-scale=1.0"/> + <script>window.CLIENT = "<?php echo $info[1]; ?>"; <?php echo $data; ?></script> + <script src="/res/access.js" type="application/javascript"/> + <script src="<?php echo $client['api'] ? "/res/access-single.js" : "/res/access-multiple.js"; ?>" type="application/javascript"/> +</head> +<body class="header-footer"> +<header> + <nav> + <div> + <a href="https://elwig.at/"><img src="/res/elwig.png" alt="Elwig Logo"/></a> + </div> + <ul> + <?php if (isset($client['clients'])) { + foreach ($client['clients'] as $id) { + $c = $CLIENT_ACCESS[$id]; + echo " <li><a href=\"#/$id\">$c[short]</a></li>\n"; + } + } else { ?> + <li><a href="#/">Übersicht</a></li> + <li><a href="#/mitglied">Mitglied</a></li> + <li><a href="#/lieferungen">Lieferungen</a></li> + <li><a href="#/anmeldungen">Anmeldungen</a></li> + <?php } ?> + </ul> + <div> + <?php if (!isset($client['clients'])) { ?> + <a href="#/mitglied" id="user"> + <div id="usertext"></div> + <img src="/res/avatar.png" alt="Avatar" style="border-radius: 50%; height: 2.25em; margin: 0.5em;"/> + </a> + <?php } ?> + </div> + </nav> +</header> +<main id="access"/> +<footer> + <a href="https://elwig.at/"><img src="/res/elwig.png" alt="Elwig"/></a> + <p> + <strong>Impressum</strong><br/> + Lorenz Stechauner, Thomas Hilscher<br/> + Österreich (Austria)<br/> + </p> + <p> + <strong>Kontakt</strong><br/> + E-Mail: <a href="mailto:contact@necronda.net">contact@necronda.net</a><br/> + </p> +</footer> +</body> +</html> diff --git a/www/res/access-multiple.js b/www/res/access-multiple.js new file mode 100644 index 0000000..0d48c63 --- /dev/null +++ b/www/res/access-multiple.js @@ -0,0 +1,189 @@ +"use strict"; + +window.CLIENT = window.CLIENT || null; +window.CLIENTS = window.CLIENTS || {}; + +function getStoredUsername(client) { + return window.localStorage.getItem(`${CLIENT}/${client}/username`); +} + +function getStoredToken(client) { + return window.localStorage.getItem(`${CLIENT}/${client}/token`); +} + +function getAuthorizationHeader(client) { + return { + 'Authorization': 'Bearer ' + window.localStorage.getItem(`${CLIENT}/${client}/token`), + }; +} + +async function authenticate(client, username, password) { + const res = await fetch(`${CLIENTS[client]['api']}/auth`, { + method: 'GET', + headers: {'Authorization': 'Basic ' + btoa(username + ':' + password)}, + }); + const json = await res.json(); + if (!res.ok) throw new ApiError(res.status, json['message']); + return json['token']; +} + +async function get(client, path) { + const res = await fetch(`${CLIENTS[client]['api']}${path}`, { + method: 'GET', + headers: {...getAuthorizationHeader(client)}, + }); + const json = await res.json(); + if (!res.ok) throw new ApiError(res.status, json['message']); + return json; +} + +async function getDeliverySchedules(client, filters, limit, offset) { + const query = []; + if (!!filters) query.push(`filters=${filters.join(',')}`); + if (!!limit) query.push(`limit=${limit}`); + if (!!offset) query.push(`offset=${offset}`); + return await get(client, `/delivery_schedules${!!query ? '?' : ''}${query.join('&')}`); +} + +async function init() { + render(); +} + +async function updateOverview(client) { + const [schedules] = await Promise.all([getDeliverySchedules(client, [`year=${getCurrentLastSeason()}`])]); + const rows = []; + const days = groupBy(schedules['data'], 'date'); + const now = new Date(); + for (const [dateString, day] of Object.entries(days)) { + const date = new Date(dateString); + const row = document.createElement('div'); + row.className = 'day'; + if (now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() === date.getDate()) + row.classList.add('today'); + row.innerHTML = `<div><span style="font-size: 0.75em; display: block">${fmtDateWeekday(date)}</span>${fmtDate(date)}</div>`; + const container = document.createElement('div'); + container.className = 'schedule-container'; + for (const schedule of day) { + const from = schedule.announcement_from !== null ? new Date(schedule.announcement_from) : null; + const to = schedule.announcement_to !== null ? new Date(schedule.announcement_to) : null; + const status = from === null && to === null ? 'Anmeldung offen' : from > now ? `Anmeldung ab ${fmtDateTime(from)}` : to > now ? `Anmeldung bis ${fmtDateTime(new Date(to - 1))}` : 'Anmeldefrist vorbei'; + const link = document.createElement('a'); + if (schedule.is_cancelled) link.className = 'cancelled'; + link.innerHTML += `<div><span style="font-size: 0.75em; display: block;">${escapeHTML(schedule.branch.name)}</span><span>${escapeHTML(schedule.description)}</span><span style="font-size: 0.75em; display: block;">${status}</span></div>`; + if (schedule.delivered_weight > 0) { + link.innerHTML += ` + <span> + <span><strong>${fmtInt(schedule.delivered_weight)} kg</strong></span> / + <span class="min-kg">${fmtInt(schedule.announced_weight)} kg</span> + (<span class="min-percent">${fmtInt(Math.round(schedule.delivered_weight / schedule.announced_weight * 100))}%</span>) + </span>`; + } else if (schedule.max_weight !== null) { + link.innerHTML += ` + <span> + <span>${fmtInt(schedule.announced_weight)} kg</span> / + <span class="min-kg">${fmtInt(schedule.max_weight)} kg</span> + (<span class="min-percent">${fmtInt(Math.round(schedule.announced_weight / schedule.max_weight * 100))}%</span>) + </span>`; + } else { + link.innerHTML += `<span><span>${fmtInt(schedule.announced_weight)} kg</span></span>`; + } + container.append(link); + } + row.appendChild(container); + rows.push(row); + } + + const main = document.getElementsByTagName('main')[0]; + main.replaceChildren(main.firstElementChild, ...rows); +} + +function render() { + const hash = window.location.hash; + const main = document.getElementById("access"); + const nav = document.getElementsByTagName("nav")[0].getElementsByTagName("ul")[0]; + for (const li of nav.children) li.className = ''; + + const client = Object.keys(CLIENTS).find(id => hash.startsWith(`#/${id}/`) || hash === `#/${id}`); + if (client === undefined) { + window.location.hash = `#/${Object.keys(CLIENTS).find(id => !!getStoredUsername(id) && !!getStoredToken(id)) || Object.keys(CLIENTS)[0]}`; + return; + } + nav.children[Object.keys(CLIENTS).indexOf(client)].className = 'active'; + + if ((!getStoredUsername(client) || !getStoredToken(client)) && window.location.hash !== `#/${client}/login`) { + window.location.hash = `#/${client}/login`; + return; + } + + if (hash === `#/${client}/login`) { + main.className = 'login'; + main.innerHTML = ` + <form onsubmit="return actionLogin(this);"> + <h1>Anmelden</h1> + <input type="text" name="username" placeholder="Benutzername" value="${getStoredUsername(client) ?? ''}"/> + <input type="password" name="password" placeholder="Kennwort"/> + <input type="hidden" name="client" value="${client}"/> + <button type="submit">Anmelden</button> + </form>`; + } else if (hash === `#/${client}`) { + main.className = 'overview'; + main.innerHTML = ` + <h1>${CLIENTS[client].name}</h1>`; + update(); + } else { + window.location.hash = `#/${client}`; + } +} + +function update() { + const hash = window.location.hash; + const client = Object.keys(CLIENTS).find(id => hash.startsWith(`#/${id}/`) || hash === `#/${id}`); + if (document.hidden || client === undefined) { + // do nothing + } else { + if (hash === `#/${client}`) { + updateOverview(client) + .then() + .catch(e => { + if (e instanceof ApiError && e.statusCode === 401) { + window.localStorage.removeItem(`${CLIENT}/${client}/token`); + window.location.hash = `#/${client}/login`; + } else { + alert(e.localizedMessage ?? ERROR_MESSAGES[e.message] ?? `Unbekannter Fehler: ${e.message}`); + } + }); + } + } +} + +document.addEventListener('DOMContentLoaded', async () => { + await init(); + setInterval(update, 60_000); +}); + +window.addEventListener('hashchange', render); + +window.addEventListener('pageshow', update) + +document.addEventListener('visibilitychange', update); + +function actionLogin(form) { + const elements = form.getElementsByClassName('error'); + for (const e of elements) form.removeChild(e); + + const client = form['client'].value; + window.localStorage.setItem(`${CLIENT}/${client}/username`, form['username'].value); + + authenticate(client, form['username'].value, form['password'].value) + .then(token => { + window.localStorage.setItem(`${CLIENT}/${client}/token`, token); + window.location.hash = `#/${client}`; + }).catch(e => { + const error = document.createElement('div'); + error.className = 'error'; + error.innerText = e.localizedMessage ?? ERROR_MESSAGES[e.message] ?? `Unbekannter Fehler\n(${e.message})`; + form.insertBefore(error, form.lastChild.previousSibling); + }); + + return false; +} diff --git a/www/res/access-single.js b/www/res/access-single.js new file mode 100644 index 0000000..5ff4ea2 --- /dev/null +++ b/www/res/access-single.js @@ -0,0 +1,330 @@ +"use strict"; + +window.CLIENT = window.CLIENT || null; +window.ELWIG_API = window.ELWIG_API || null; + +function getCredentialsUsername() { + return window.localStorage.getItem(`${CLIENT}/username`); +} + +function getCredentialsPassword() { + return window.localStorage.getItem(`${CLIENT}/password`); +} + +function getBasicAuth() { + return { + 'Authorization': 'Basic ' + btoa(getCredentialsUsername() + ':' + getCredentialsPassword()), + }; +} + +async function _get(path) { + const res = await fetch(`${ELWIG_API}${path}`, { + method: 'GET', + headers: {...getBasicAuth()}, + }); + const json = await res.json(); + if (!res.ok) throw new ApiError(res.status, json['message']); + return json; +} + +async function get(path) { + return (await _get(path))['data']; +} + +async function getMember(mgnr) { + return await get(`/members/${mgnr}`); +} + +async function getWineVarieties() { + return Object.fromEntries((await get('/wine/varieties')).map(item => [item['sortid'], item])); +} + +async function getWineQualityLevels() { + return Object.fromEntries((await get('/wine/quality_levels')).map(item => [item['qualid'], item])); +} + +async function getWineAttributes() { + return Object.fromEntries((await get('/wine/attributes')).map(item => [item['attrid'], item])); +} + +async function getWineCultivations() { + return Object.fromEntries((await get('/wine/cultivations')).map(item => [item['cultid'], item])); +} + +async function getModifiers() { + const list = await get('/modifiers'); + const dict = {}; + for (const item of list) { + if (!dict[item['year']]) dict[item['year']] = {}; + dict[item['year']][item['modid']] = item; + } + return dict; +} + +async function getDeliveries(filters, limit, offset) { + const query = ['sort=reverse']; + if (!!filters) query.push(`filters=${filters.join(',')}`); + if (!!limit) query.push(`limit=${limit}`); + if (!!offset) query.push(`offset=${offset}`); + return await _get(`/deliveries${!!query ? '?' : ''}${query.join('&')}`); +} + +async function getDeliveryStats(filters, detail) { + const query = []; + if (!!filters) query.push(`filters=${filters.join(',')}`); + if (!!detail) query.push(`detail=${detail}`); + return await _get(`/deliveries/stat${!!query ? '?' : ''}${query.join('&')}`); +} + +async function getDeliverySchedules(filters, limit, offset) { + const query = []; + if (!!filters) query.push(`filters=${filters.join(',')}`); + if (!!limit) query.push(`limit=${limit}`); + if (!!offset) query.push(`offset=${offset}`); + return await _get(`/delivery_schedules${!!query ? '?' : ''}${query.join('&')}`); +} + +async function getDeliveryAnnouncements(filters, limit, offset) { + const query = []; + if (!!filters) query.push(`filters=${filters.join(',')}`); + if (!!limit) query.push(`limit=${limit}`); + if (!!offset) query.push(`offset=${offset}`); + return await _get(`/delivery_announcements${!!query ? '?' : ''}${query.join('&')}`); +} + +async function load() { + const main = document.getElementById("access"); + const form = main.getElementsByTagName("form")[0]; + if (form) { + const elements = form.getElementsByClassName('error'); + for (const e of elements) form.removeChild(e); + } + try { + window.MEMBER = await getMember(getCredentialsUsername()); + const txt = document.getElementById('usertext'); + txt.innerHTML = `${MEMBER.prefix ?? ''} ${MEMBER.given_name ?? ''} ${MEMBER.middle_names ?? ''} ${MEMBER.name ?? ''} ${MEMBER.suffix ?? ''}<br/><div>MgNr. ${MEMBER.mgnr}</div>`; + window.WINE_VARIETIES = await getWineVarieties(); + window.WINE_QUALITY_LEVELS = await getWineQualityLevels(); + window.WINE_ATTRIBUTES = await getWineAttributes(); + window.WINE_CULTIVATIONS = await getWineCultivations(); + window.MODIFIERS = await getModifiers(); + return true; + } catch (e) { + if (form) { + window.localStorage.removeItem(`${CLIENT}/password`); + const error = document.createElement('div'); + error.className = 'error'; + error.innerText = e.localizedMessage ?? ERROR_MESSAGES[e.message] ?? 'Unbekannter Fehler'; + form.insertBefore(error, form.lastChild.previousSibling); + } else { + window.location.hash = '#/login'; + } + return false; + } +} + +async function init() { + if (!getCredentialsUsername() || !getCredentialsPassword()) { + window.location.hash = '#/login'; + render(); + return; + } + await load(); + render(); +} + +async function updateOverview() { + const [schedules] = await Promise.all([getDeliverySchedules([`year=${getCurrentLastSeason()}`])]); + + const main = document.getElementsByTagName('main')[0]; + const days = groupBy(schedules.data, 'date'); + for (const [dateString, day] of Object.entries(days)) { + const row = document.createElement('div'); + row.className = 'day'; + const date = new Date(dateString); + row.innerHTML = `<div><span style="font-size: 0.75em; display: block">${fmtDateWeekday(date)}</span>${fmtDate(date)}</div>`; + const container = document.createElement('div'); + container.className = 'schedule-container'; + for (const schedule of day) { + const now = new Date(); + const from = schedule.announcement_from !== null ? new Date(schedule.announcement_from) : null; + const to = schedule.announcement_to !== null ? new Date(schedule.announcement_to) : null; + const status = from === null && to === null ? 'Anmeldung offen' : from > now ? `Anmeldung ab ${fmtDateTime(from)}` : to > now ? `Anmeldung bis ${fmtDateTime(new Date(to - 1))}` : 'Anmeldefrist vorbei'; + const link = document.createElement('a'); + link.href = `#/anmelden/${schedule.year}/${schedule.dsnr}` + link.innerHTML += `<div><span>${schedule.description}</span><span style="font-size: 0.75em; display: block;">${status}</span></div>`; + if (schedule.delivered_weight > 0) { + link.innerHTML += ` + <span> + <span><strong>${fmtInt(schedule.delivered_weight)} kg</strong></span> / + <span class="min-kg">${fmtInt(schedule.announced_weight)} kg</span> + (<span class="min-percent">${fmtInt(Math.round(schedule.delivered_weight / schedule.announced_weight * 100))}%</span>) + </span>`; + } else { + link.innerHTML += ` + <span> + <span>${fmtInt(schedule.announced_weight)} kg</span> / + <span class="min-kg">${fmtInt(schedule.max_weight)} kg</span> + (<span class="min-percent">${fmtInt(Math.round(schedule.announced_weight / schedule.max_weight * 100))}%</span>) + </span>`; + } + container.append(link); + } + row.appendChild(container); + main.appendChild(row); + } +} + +async function updateDeliveries(year) { + const filters = [`mgnr=${MEMBER.mgnr}`, `year=${year}`]; + const [deliveries, stat] = await Promise.all([getDeliveries(filters), getDeliveryStats(filters)]); + + const tbody = document.getElementById('delivery-list'); + tbody.innerHTML = ''; + for (const delivery of deliveries.data) { + const tr = document.createElement('tr'); + tr.style.background = '#C0C0C0'; + tr.innerHTML = ` + <th colspan="2">${delivery.lsnr}</th> + <td>${fmtDate(new Date(delivery.date))}</td> + <td>${delivery.time.substring(0, 5)}</td> + <td colspan="2">${delivery.branch.name}</td> + <td colspan="7">${delivery.comment ?? ''}</td>`; + tbody.appendChild(tr); + for (const part of delivery.parts) { + const tr = document.createElement('tr'); + const defaultQualityLevel = getDefaultQualityLevel(part.gradation.kmw); + tr.innerHTML = ` + <th>${part.dpnr}</th> + <td colspan="2">${WINE_VARIETIES[part.variety.sortid]?.name ?? ''}</td> + <td colspan="2" style="font-weight: bold;">${WINE_CULTIVATIONS[part.cultivation?.cultid]?.name ?? ''}</td> + <td colspan="2" style="font-weight: bold;">${WINE_ATTRIBUTES[part.attribute?.attrid]?.name ?? ''}</td> + <td class="${defaultQualityLevel['qualid'] != part.quality_level.qualid ? 'abgewertet' : ''}">${WINE_QUALITY_LEVELS[part.quality_level.qualid]?.name ?? ''}</td> + <td class="center">${fmtOe(part.gradation.oe)}</td> + <td class="center">${fmtKmw(part.gradation.kmw)}</td> + <td class="number">${fmtInt(part.weight)}</td> + <td>${part.modifiers.map(m => MODIFIERS[delivery.year][m.modid]).sort(m => m.ordering).map(m => m.name).join(' / ')}</td> + <td>${part.comment ?? ''}</td>`; + tbody.appendChild(tr); + } + } + + const element = document.getElementById('delivery-stat'); + element.innerText = `(Teil-)Lieferungen: ${fmtInt(stat.data.total.count)} (${fmtInt(stat.data.total.parts)}), Gewicht: ${fmtInt(stat.data.total.weight.sum)} kg`; +} + +function render() { + const hash = window.location.hash; + const main = document.getElementById("access"); + const nav = document.getElementsByTagName("nav")[0].getElementsByTagName("ul")[0]; + for (const li of nav.children) li.className = ''; + if (hash === '#/login') { + main.className = 'login'; + main.innerHTML = ` + <form onsubmit="return actionLogin(this);"> + <h1>Anmelden</h1> + <input type="text" name="username" placeholder="Mitgliedsnummer" value="${getCredentialsUsername() ?? ''}"/> + <input type="password" name="password" placeholder="Kennwort"/> + <button type="submit">Anmelden</button> + </form>`; + } else if (hash === '#/') { + nav.children[0].className = 'active'; + main.className = 'overview'; + main.innerHTML = ` + <h1>Übersicht</h1>`; + updateOverview().then(); + } else if (hash === '#/mitglied') { + nav.children[1].className = 'active'; + main.className = 'member'; + main.innerHTML = ` + <h1>Mitglied</h1> + <button onclick="actionLogout()">Abmelden</button> + <pre>${JSON.stringify(MEMBER, null, 2)}</pre>`; + } else if (hash === '#/lieferungen') { + nav.children[2].className = 'active'; + main.className = 'deliveries'; + main.innerHTML = ` + <h1>Lieferungen</h1> + <form> + <div> + <label for="season">Saison:</label> + <input name="season" type="number" min="1900" max="9999" + value="${getCurrentLastSeason()}" onchange="updateDeliveries(this.value).then()"/> + </div> + <div id="delivery-stat"/> + </form> + <table style="width: 100%;"> + <colgroup> + <col style="width: 50px;"/> + <col style="width: 70px;"/> + <col style="width: 100px;"/> + <col style="width: 60px;"/> + <col style="width: 50px;"/> + <col style="width: 70px;"/> + <col style="width: 50px;"/> + <col style="width: 120px;"/> + <col style="width: 50px;"/> + <col style="width: 50px;"/> + <col style="width: 60px;"/> + <col style="min-width: 80px;"/> + </colgroup> + <thead> + <tr> + <th rowspan="2"></th> + <th rowspan="2" colspan="2">Sorte</th> + <th rowspan="2" colspan="2">Bewirt.</th> + <th rowspan="2" colspan="2">Attribut</th> + <th rowspan="2">Qualitätsstufe</th> + <th colspan="2" class="center">Gradation</th> + <th class="center">Gewicht</th> + <th rowspan="2">Zu-/Abschläge</th> + <th></th> + </tr> + <tr> + <th class="unit">[°Oe]</th> + <th class="unit">[°KMW]</th> + <th class="unit">[kg]</th> + </tr> + </thead> + <tbody id="delivery-list"></tbody> + </table>`; + updateDeliveries(getCurrentLastSeason()).then(); + } else if (hash === '#/anmeldungen') { + nav.children[3].className = 'active'; + main.className = 'announcements'; + main.innerHTML = '<h1>Anmeldungen</h1>'; + } else if (hash.startsWith('#/anmelden/')) { + nav.children[3].className = 'active'; + main.className = 'announce'; + main.innerHTML = '<h1>Anmelden</h1>'; + } else { + window.location.hash = `#/`; + } +} + +document.addEventListener('DOMContentLoaded', async () => { + await init(); +}); + +window.addEventListener('hashchange', () => { + if ((!getCredentialsUsername() || !getCredentialsPassword()) && window.location.hash !== '#/login') { + window.location.hash = '#/login'; + return; + } + render(); +}); + +function actionLogin(form) { + window.localStorage.setItem(`${CLIENT}/username`, form.username.value); + window.localStorage.setItem(`${CLIENT}/password`, form.password.value); + load().then(success => { + if (success) window.location.hash = '#/'; + }); + return false; +} + +function actionLogout() { + window.localStorage.removeItem(`${CLIENT}/username`); + window.localStorage.removeItem(`${CLIENT}/password`); + window.location.reload(); +} diff --git a/www/res/access.js b/www/res/access.js new file mode 100644 index 0000000..572d7d5 --- /dev/null +++ b/www/res/access.js @@ -0,0 +1,80 @@ +"use strict"; + +const ERROR_MESSAGES = { + 401: 'Ungültige Anmeldedaten', + 'NetworkError when attempting to fetch resource.': 'Netzwerkfehler', +}; + +class ApiError extends Error { + constructor(statusCode, message) { + super(statusCode + ' - ' + message); + this.statusCode = statusCode; + this.name = 'ApiError'; + this.localizedMessage = ERROR_MESSAGES[statusCode]; + } +} + +function escapeHTML(str) { + const p = document.createElement("p"); + p.appendChild(document.createTextNode(str)); + return p.innerHTML; +} + +function fmtDate(date) { + return date.toLocaleDateString('de-AT', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function fmtDateTime(date) { + return date.toLocaleDateString('de-AT', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function fmtDateWeekday(date) { + return date.toLocaleDateString('de-AT', { + weekday: 'long', + }); +} + +function fmtOe(oe) { + return oe.toLocaleString('de-AT', {minimumFractionDigits: 0, maximumFractionDigits: 0}); +} + +function fmtKmw(kmw) { + return kmw.toLocaleString('de-AT', {minimumFractionDigits: 1, maximumFractionDigits: 1}); +} + +function fmtInt(num) { + return num.toLocaleString('de-AT', {minimumFractionDigits: 0, maximumFractionDigits: 0}); +} + +function groupBy(list, key) { + return list.reduce((groups, value) => { + (groups[value[key]] = groups[value[key]] ?? []).push(value); + return groups; + }, {}); +} + +function getCurrentLastSeason() { + const date = new Date(); + return date.getFullYear() - ((date.getMonth() + 1) <= 6 ? 1 : 0); +} + +function getDefaultQualityLevel(kmw) { + const list = Object.values(WINE_QUALITY_LEVELS).filter(q => !q['is_predicate']).sort((a, b) => a['min_kmw'] - b['min_kmw']); + let last = list[0]; + for (const q of list) { + if (q['min_kmw'] > kmw) { + return last; + } + last = q; + } + return last; +} diff --git a/www/res/avatar.png b/www/res/avatar.png new file mode 100644 index 0000000..77b948d Binary files /dev/null and b/www/res/avatar.png differ diff --git a/www/res/elwig.png b/www/res/elwig.png new file mode 100644 index 0000000..f2764a2 Binary files /dev/null and b/www/res/elwig.png differ diff --git a/www/res/style.css b/www/res/style.css new file mode 100644 index 0000000..d9e99b6 --- /dev/null +++ b/www/res/style.css @@ -0,0 +1,340 @@ + +/**** Shared ****/ + +* { + box-sizing: border-box; +} + +:root { + font-family: 'Arial', sans-serif; + scroll-behavior: smooth; + --main-color: #A040C0; + --light-color: #D098E0; + --accent-color: #C0F080; + --bg-color: #EBEFE7; + --blur-color: #60804020; + --blur: 16px; +} + +body { + margin: 0; + position: relative; +} + +body.header-footer { + min-height: calc(100vh + 8em); + padding: 3em 0 8em 0; +} + +header { + position: fixed; + top: 0; + width: 100%; + height: 3em; + box-shadow: 0 0 0.5em #00000060; + background-color: #FFFFFF; + z-index: 10; +} + +header img { + height: 2.5em; + margin: 0.25em; +} + +nav { + height: 100%; + display: flex; + justify-content: space-between; +} + +nav > *:first-child, +nav > *:last-child { + flex: 100px 1 1; +} + +nav > *:last-child { + text-align: right; +} + +nav ul { + display: flex; + list-style-type: none; + margin: 0; + padding: 0; + justify-content: center; + align-items: center; + height: 100%; +} + +nav li { + flex: 100px 1 1; +} + +nav li a { + text-decoration: none; + padding: 1em 2em; + color: #808080; +} + +nav li a:hover, +nav li.active a{ + color: var(--main-color); +} + +nav a.flag { + text-decoration: none; +} + +nav a.flag div { + display: inline-block; + font-size: 1.25em; + width: 1em; + margin: 0.75rem 1rem; +} + +nav a.flag[href='/de/'] div::before, +nav a.flag[href='/en/']:hover div::before, +nav a.flag[href='/en/']:focus div::before { + content: '\1F1EC\1F1E7'; /* GB */ +} + +nav a.flag[href='/en/'] div::before, +nav a.flag[href='/de/']:hover div::before, +nav a.flag[href='/de/']:focus div::before { + content: '\1F1E6\1F1F9'; /* AT */ +} + +footer { + height: 8em; + padding: 1em; + width: 100%; + box-shadow: 0 0 0.5rem #00000060; + background-color: #404040; + color: #FFFFFF; + display: flex; + justify-content: center; + gap: 2em; + position: absolute; + bottom: 0; +} + +footer img { + height: 4em; + width: 4em; +} + +footer a { + align-self: center; +} + +@media screen and (max-width: 800px) { + footer { + flex-direction: column; + height: unset; + align-items: center; + gap: 1em; + padding-top: 3em; + } + footer > *:not(:first-child) { + width: 300px; + } +} + +p a, +table a { + color: var(--main-color); +} + +/**** Access ****/ + +main#access { + width: calc(100% - 2em); + max-width: 1200px; + margin: 4em auto 4em auto; + border-radius: 4px; + box-shadow: 0 0 0.5em #00000060; + padding: 1em 2em; +} + +main#access h1 { + text-align: center; +} + +main#access.login { + max-width: 500px; +} + +main#access.login h1 { + margin: 1rem 0 1rem 0; +} + +main#access.login form { + text-align: center; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1em; + margin: 1em 0; +} + +main#access.login form input, +main#access.login form button { + font-size: 1em; + border: 1px solid #C0C0C0; + border-radius: 4px; + padding: 0.25em 0.5em; +} + +main#access.login form .error { + font-size: 1em; + color: #C00000; + border: 1px solid #C00000; + border-radius: 4px; + padding: 0.25em 0.5em; + text-align: center; + font-weight: bold; + margin: 0; +} + +#user { + display: flex; + justify-content: end; + align-items: center; + text-decoration: none; + color: inherit; + position: absolute; + right: 0; + height: 100%; +} + +#user:hover { + color: var(--main-color); +} + +#usertext { + font-size: 0.875em; +} + +#usertext > div { + font-size: 0.75em; + opacity: 0.5; +} + +main .number { + text-align: right; +} + +main.deliveries form { + margin: 1em; + display: flex; + align-items: center; + align-content: center; + gap: 1em; +} + +.abgewertet { + color: #C00000; + font-weight: bold; +} + +main.overview .day { + border-radius: 4px; + padding-left: 1.5em; + background: #E0E0E0; + margin: 1em 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 2em; + height: 10em; +} + +main.overview .day.today { + background-color: var(--light-color); +} + +main.overview .schedule-container { + background: #00000020; + border-radius: 4px; + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; + justify-content: center; +} + +main.overview .schedule-container a { + border-radius: 4px; + color: unset; + text-decoration: none; + height: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1em; + transition: background-color 0.125s; +} + +main.overview .schedule-container a.cancelled > div { + color: #A00000; + text-decoration: line-through; +} + +main.overview .schedule-container a.cancelled > span { + opacity: 0.25; +} + +main.overview .schedule-container a:hover { + background-color: #00000020; +} + +.min-percent { + min-width: 2.625em; + display: inline-block; + text-align: right; +} + +.min-kg { + min-width: 5em; + display: inline-block; + text-align: right; +} + +@media screen and (max-width: 1000px) { + main#access { + padding: 1em 0.5em; + margin-left: 0; + margin-right: 0; + width: 100%; + } + main.overview .day { + flex-direction: column; + height: unset; + padding-left: 0; + gap: 0.5em; + padding-top: 0.5em; + } + main.overview .day > div:first-child { + text-align: center; + } + main.overview .schedule-container { + height: unset; + width: 100%; + } + main.overview .schedule-container a { + padding: 1em; + } +} + +@media screen and (max-width: 650px) { + main.overview .schedule-container a { + flex-direction: column; + } + main.overview .schedule-container a > *:first-child { + width: 100%; + } + main.overview .schedule-container a > *:last-child { + width: 100%; + text-align: right; + } +} diff --git a/www/robots.txt b/www/robots.txt new file mode 100644 index 0000000..31b04a1 --- /dev/null +++ b/www/robots.txt @@ -0,0 +1,3 @@ +# robots.txt for access.elwig.at +User-Agent: * +Disallow: /