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: /