M4: PWA + Mobile-UX – Manifest/Service-Worker + Icons, Auf/Ab-Reorder, FAB, Touch-Targets + Safe-Area, Nav-Entdupidung, Heute-Highlight, Aktions-Trennung (Abhaken vs. Bearbeiten/Löschen), OIDC-Fixes + PWA-Tests

This commit is contained in:
2026-09-20 13:04:52 +02:00
parent 0db09bf098
commit f4c20d0504
23 changed files with 938 additions and 161 deletions
+9 -5
View File
@@ -3,11 +3,15 @@
{% block title %}Start · svc-essensplanung{% endblock %}
{% block body %}
<h1>{{ name }}</h1>
<h1>Willkommen, {{ name }}!</h1>
<p class="lead" style="margin-top: .4rem;">Deine Woche auf einen Blick – was steht an und was schmeckt mal wieder?</p>
<div class="card">
<p>Willkommen zur Essensplanung! (M0-Skelett)</p>
<p><strong>E-Mail:</strong> {{ email }}</p>
<p><strong>Redirect-Pfad:</strong> <code>{{ redirect }}</code></p>
<p>M1: CRUD-Slice (Dish-Create/Update/Delete/List) folgt.</p>
<div class="actions">
<a class="btn" href="{{ path('mahlzeiten_index') }}">Zur Planungsliste</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
</div>
<p class="muted" style="margin: 1.1rem 0 0;">Angemeldet als {{ email }}</p>
</div>
{% endblock %}
+380 -52
View File
@@ -2,102 +2,423 @@
<html lang="de" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{% block title %}svc-essensplanung{% endblock %}</title>
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
<meta name="theme-color" content="#3f6d4e">
<link rel="icon" href="{{ asset('icons/icon-192.png') }}" type="image/png">
<link rel="apple-touch-icon" href="{{ asset('icons/apple-touch-icon.png') }}">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Essen">
<style>
:root {
--bg: #f4f4f4;
--fg: #112233;
--card: #fff;
--accent: #0a7bff;
--muted: #667;
--warn: #b45309;
--danger: #b91c1c;
--bg: #f4eee1;
--bg-soft: #ece4d2;
--card: #fffdf8;
--fg: #2e2a22;
--muted: #877d69;
--border: #e6ddc9;
--border-strong: #d8cdb4;
--accent: #3f6d4e;
--accent-strong: #315a3e;
--accent-soft: #e2ecdf;
--danger: #a8442a;
--danger-strong: #8f3820;
--danger-soft: #f6e6de;
--ring: rgba(63, 109, 78, .22);
--shadow: 0 1px 2px rgba(76, 60, 32, .05), 0 5px 18px rgba(76, 60, 32, .08);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
font: 16px/1.55 ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--fg);
background: linear-gradient(180deg, #f7f2e7 0%, var(--bg) 320px) fixed;
-webkit-tap-highlight-color: transparent;
}
a { color: var(--accent); }
/* ---------- Header ---------- */
header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--card);
box-shadow: 0 1px 0 #ddd;
gap: .5rem 1rem;
flex-wrap: wrap;
padding: calc(.65rem + env(safe-area-inset-top, 0px)) 1.25rem .65rem;
background: rgba(255, 253, 248, .94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
header a {
color: var(--accent);
.brand {
display: inline-flex;
align-items: center;
gap: .5rem;
color: var(--fg);
text-decoration: none;
margin-left: 1rem;
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -.01em;
}
main {
max-width: 40rem;
margin: 2rem auto;
padding: 0 1rem;
.brand svg { color: var(--accent); flex: none; }
nav { display: flex; align-items: center; gap: .15rem; flex-wrap: wrap; }
nav a {
color: var(--muted);
text-decoration: none;
font-weight: 550;
font-size: .95rem;
padding: .35rem .8rem;
border-radius: 999px;
transition: background .15s, color .15s;
}
nav a:hover { color: var(--fg); background: var(--bg-soft); }
nav a.nav-logout { margin-left: .5rem; color: var(--muted); font-size: .88rem; }
nav a.nav-logout:hover { color: var(--danger); background: var(--danger-soft); }
/* ---------- Layout ---------- */
main { max-width: 44rem; margin: 1.75rem auto 3rem; padding: 0 1rem; }
h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 750;
letter-spacing: -.02em;
}
h2 {
font-size: 1.12rem;
font-weight: 700;
letter-spacing: -.01em;
margin: 1.6rem 0 .55rem;
display: flex;
align-items: center;
gap: .5rem;
}
.subtitle { color: var(--muted); font-size: .95rem; margin-top: .25rem; }
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.1rem;
}
.card {
background: var(--card);
padding: 1.25rem;
border-radius: 0.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
padding: 1.4rem;
border-radius: 1rem;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.muted { color: var(--muted); font-size: 0.9rem; }
.muted { color: var(--muted); font-size: .9rem; }
.lead { font-size: 1.02rem; color: var(--fg); margin: .2rem 0 1rem; }
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.empty { text-align: center; color: var(--muted); margin: 1.5rem 0 .5rem; padding: 1.25rem 1rem; background: var(--bg-soft); border-radius: .8rem; }
/* ---------- Buttons ---------- */
.btn {
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .45rem;
background: var(--accent);
color: #fff;
padding: 0.4rem 0.8rem;
border-radius: 0.35rem;
padding: .55rem 1.15rem;
border-radius: 999px;
text-decoration: none;
border: none;
border: 1px solid transparent;
font: inherit;
font-weight: 600;
font-size: .95rem;
cursor: pointer;
box-shadow: 0 1px 2px rgba(49, 90, 62, .28);
transition: background .15s, box-shadow .15s, transform .06s;
white-space: nowrap;
}
.btn:hover { background: var(--accent-strong); box-shadow: 0 2px 6px rgba(49, 90, 62, .3); }
.btn:active { transform: translateY(1px); }
.btn svg { width: 1.05em; height: 1.05em; flex: none; }
.btn-link {
background: transparent;
color: var(--muted);
box-shadow: none;
font-weight: 550;
}
.btn-link:hover { color: var(--fg); background: var(--bg-soft); }
.btn-danger {
background: transparent;
color: var(--danger);
box-shadow: none;
}
.btn-danger:hover { background: var(--danger-soft); color: var(--danger-strong); }
.btn:focus-visible, nav a:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ring);
}
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.action-haupt { display: flex; }
.action-neben {
display: flex;
gap: .4rem;
align-items: center;
padding-left: .65rem;
margin-left: .1rem;
border-left: 1px solid var(--border);
}
.action-neben .btn { font-size: .87rem; padding: .5rem .75rem; }
.action-neben form { display: inline-flex; }
/* ---------- Liste ---------- */
.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
.item {
display: flex;
justify-content: space-between;
align-items: center;
gap: .75rem 1rem;
flex-wrap: wrap;
padding: .9rem 1.05rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: .85rem;
transition: box-shadow .15s, border-color .15s, background .15s;
}
.item:hover { box-shadow: var(--shadow); border-color: var(--border-strong); }
.item-main { display: flex; align-items: flex-start; gap: .15rem; min-width: 0; }
.item-name { min-width: 0; }
.item-name strong, .item-name .name {
font-size: 1.07rem;
font-weight: 680;
letter-spacing: -.01em;
color: var(--fg);
}
.notiz { color: var(--muted); font-size: .9rem; margin-top: .1rem; }
.drag-handle {
flex: none;
align-self: center;
cursor: grab;
color: #b6ab90;
margin-right: .6rem;
padding: .45rem .3rem;
border-radius: .45rem;
line-height: 0;
user-select: none;
transition: color .15s, background .15s;
}
.drag-handle:hover { color: var(--muted); background: var(--bg-soft); }
.updown { display: none; }
.updown button {
flex: none;
width: 2.2rem;
height: 2.2rem;
margin: .1rem 0;
padding: 0;
border-radius: .55rem;
line-height: 0;
color: #b6ab90;
background: var(--bg-soft);
border: 1px solid var(--border);
cursor: pointer;
}
.btn:hover { opacity: 0.92; }
.btn-danger { background: var(--danger); }
.btn-link { background: transparent; color: var(--muted); }
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
.drag-handle { cursor: grab; color: var(--muted); font-weight: 600; margin-right: 0.55rem; user-select: none; }
.item-drag { opacity: 0.4; }
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
.status { font-size: 0.8rem; color: var(--muted); }
.status-gegessen { color: #15573b; }
.field { margin-bottom: 1rem; }
.field > label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
.updown button:active { color: var(--accent-strong); background: var(--accent-soft); border-color: #cfe0cd; }
.updown button:disabled { opacity: .35; pointer-events: none; }
.item-drag { opacity: .35; }
.tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }
.tag {
background: var(--accent-soft);
color: var(--accent-strong);
padding: .13rem .6rem;
border-radius: 999px;
font-size: .78rem;
font-weight: 650;
letter-spacing: .01em;
}
.tag-mo { background: #e3efe2; color: #2f5d39; }
.tag-di { background: #ddefed; color: #2a5f56; }
.tag-mi { background: #e1ebf6; color: #2f5578; }
.tag-do { background: #ebe5f4; color: #54437a; }
.tag-fr { background: #fcedda; color: #94621c; }
.tag-sa { background: #fae6e9; color: #93404d; }
.tag-so { background: #f9f2d4; color: #7d6414; }
.status {
display: inline-flex;
align-items: center;
gap: .4rem;
margin-top: .55rem;
padding: .16rem .65rem;
border-radius: 999px;
font-size: .79rem;
font-weight: 650;
color: var(--muted);
background: var(--bg-soft);
}
.status::before {
content: "";
width: .45rem;
height: .45rem;
border-radius: 50%;
background: #c9bfa6;
}
.status-gegessen { color: var(--accent-strong); background: var(--accent-soft); }
.status-gegessen::before { background: var(--accent); }
.item-gegessen { background: #faf6ec; }
.item-gegessen .item-name strong, .item-gegessen .item-name .name {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: #b6ab90;
text-decoration-thickness: 1.5px;
}
.item .actions { margin-left: auto; }
/* ---------- Formular ---------- */
.field { margin-bottom: 1.15rem; }
.field > label { display: block; font-weight: 650; margin-bottom: .35rem; font-size: .97rem; }
.field input[type="text"], .field textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.35rem;
padding: .6rem .85rem;
border: 1px solid var(--border-strong);
border-radius: .6rem;
font: inherit;
background: #fff;
color: var(--fg);
transition: border-color .15s, box-shadow .15s;
}
.field input[type="text"]:focus, .field textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--ring);
}
.field textarea { resize: vertical; min-height: 4.2rem; }
.req { color: var(--danger); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.3rem; }
.checkbox { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 400; cursor: pointer; }
.flash { padding: 0.6rem 0.9rem; border-radius: 0.4rem; margin-bottom: 1rem; }
.flash-notice { background: #e6f6ec; color: #15573b; }
.flash-error { background: #fdecec; color: #8a1f1f; }
.checkboxes { display: flex; flex-wrap: wrap; gap: .55rem 1rem; margin-top: .4rem; }
.checkbox {
display: inline-flex;
align-items: center;
gap: .45rem;
font-weight: 500;
cursor: pointer;
padding: .3rem .7rem;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
transition: border-color .15s, background .15s;
}
.checkbox:hover { border-color: var(--border-strong); }
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; margin: 0; }
.checkbox:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
/* ---------- Flash ---------- */
.flash {
padding: .7rem 1rem;
border-radius: .75rem;
margin-bottom: 1rem;
font-weight: 550;
border: 1px solid transparent;
}
.flash-notice { background: var(--accent-soft); color: var(--accent-strong); border-color: #cfe0cd; }
.flash-error { background: var(--danger-soft); color: var(--danger-strong); border-color: #eccfc4; }
/* ---------- Inspiration ---------- */
.vorschlag-meta { color: var(--muted); font-size: .85rem; margin-top: .3rem; }
.vorschlag-meta b { color: var(--accent-strong); font-weight: 650; }
/* ---------- Tagesansicht ---------- */
h2[data-count]::after {
content: attr(data-count);
font-size: .76rem;
font-weight: 650;
color: var(--muted);
background: var(--bg-soft);
padding: .08rem .55rem;
border-radius: 999px;
line-height: 1.25;
}
h2[data-heute] { color: var(--accent-strong); }
h2[data-heute]::after {
content: attr(data-count) " · heute";
color: var(--accent-strong);
background: var(--accent-soft);
}
h2[data-heute] + .list .item { border-color: #cfe0cd; }
/* ---------- FAB ---------- */
.fab {
display: none;
position: fixed;
z-index: 20;
right: calc(1rem + env(safe-area-inset-right, 0px));
bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
box-shadow: 0 4px 14px rgba(49, 90, 62, .4);
}
.fab:active { background: var(--accent-strong); transform: translateY(1px); }
.fab svg { width: 1.5rem; height: 1.5rem; }
/* ---------- Mobil ---------- */
@media (max-width: 640px) {
main { margin-top: 1.1rem; padding: 0 .8rem; padding-bottom: 6rem; }
.card { padding: 1.1rem; }
.btn { min-height: 2.75rem; }
.fab { display: flex; }
.drag-handle { display: none; }
.updown { display: inline-flex; flex-direction: column; align-self: center; margin-right: .6rem; }
.item { flex-direction: column; align-items: stretch; }
.item .actions { margin-left: 0; flex-direction: column; align-items: stretch; }
.item .action-haupt { width: 100%; }
.item .action-haupt .btn { width: 100%; }
.item .action-neben {
width: 100%;
border-left: none;
border-top: 1px solid var(--border);
margin-left: 0;
padding-left: 0;
padding-top: .5rem;
margin-top: .15rem;
}
.item .action-neben .btn,
.item .action-neben form { flex: 1; }
.item .action-neben .btn { width: auto; }
.item .action-neben form .btn { width: 100%; }
.page-head { flex-direction: column; align-items: stretch; }
.page-head .actions { width: 100%; }
.page-head .actions .btn { flex: 1; }
}
</style>
{% block stylesheets %}{% endblock %}
</head>
<body>
<header>
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
<a class="brand" href="{{ path('home') }}">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true">
<path d="M4 12h16a8 8 0 0 1-16 0Z"/>
<path d="M9.2 8.2c-.6-1.4.5-2-.1-3.6"/>
<path d="M13.8 8.2c-.6-1.4.5-2-.1-3.6"/>
</svg>
Essensplanung
</a>
<nav>
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
<a href="{{ path('mahlzeiten_tage') }}">Tage</a>
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
<a href="{{ path('logout') }}">Abmelden</a>
<a class="nav-logout" href="{{ path('logout') }}">Abmelden</a>
</nav>
</header>
{% block fab %}
<a class="fab" href="{{ path('mahlzeiten_neu') }}" aria-label="Neue Mahlzeit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
</a>
{% endblock %}
<main>
{% for label, messages in app.flashes %}
{% for message in messages %}
@@ -108,5 +429,12 @@
</main>
<script src="{{ asset('js/htmx.min.js') }}"></script>
{% block javascripts %}{% endblock %}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('{{ asset('sw.js') }}').catch(function () {});
});
}
</script>
</body>
</html>
+87 -38
View File
@@ -3,49 +3,98 @@
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
@param MahlzeitResponse m
@param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
#}
<li class="item" id="mahlzeit-{{ m.id }}"
@param MahlzeitResponse m
@param bool sortierbar optional: zeigt Drag-Handle + Auf/Ab-Buttons + data-id (Planungsliste)
#}
<li class="item {{ m.status.value == 'gegessen' ? 'item-gegessen' : '' }}" id="mahlzeit-{{ m.id }}"
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
<div>
<div class="item-main">
{% if sortierbar is defined and sortierbar %}
<span class="drag-handle" title="Ziehen zum Sortieren">⋮</span>
<span class="drag-handle" title="Ziehen zum Sortieren" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5.5" cy="3" r="1.4"/><circle cx="10.5" cy="3" r="1.4"/>
<circle cx="5.5" cy="8" r="1.4"/><circle cx="10.5" cy="8" r="1.4"/>
<circle cx="5.5" cy="13" r="1.4"/><circle cx="10.5" cy="13" r="1.4"/>
</svg>
</span>
<span class="updown">
<button type="button" class="updown-btn" data-dir="-1" aria-label="Nach oben verschieben">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 15 6-6 6 6"/>
</svg>
</button>
<button type="button" class="updown-btn" data-dir="1" aria-label="Nach unten verschieben">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
</span>
{% endif %}
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="muted">{{ m.notiz }}</div>
{% endif %}
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
{% if m.status.value == 'gegessen' and m.gegessenAm %}
· {{ m.gegessenAm|date('d.m.Y H:i') }}
<div class="item-name">
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="notiz">{{ m.notiz }}</div>
{% endif %}
</span>
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag tag-{{ tag.value }}">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
{% if m.status.value == 'gegessen' and m.gegessenAm %}
· {{ m.gegessenAm|date('d.m.Y H:i') }}
{% endif %}
</span>
</div>
</div>
<div class="actions">
{% if m.status.value == 'geplant' %}
<button type="button" class="btn"
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Abhaken</button>
{% else %}
<button type="button" class="btn btn-link"
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Aufheben</button>
{% endif %}
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">Bearbeiten</a>
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
<div class="action-haupt">
{% if m.status.value == 'geplant' %}
<button type="button" class="btn"
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4.5 12.5 9.5 17.5 19.5 6.5"/>
</svg>
Abhaken
</button>
{% else %}
<button type="button" class="btn btn-link"
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>
</svg>
Aufheben
</button>
{% endif %}
</div>
<div class="action-neben">
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
Bearbeiten
</a>
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
<button type="submit" class="btn btn-danger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18"/>
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M10 11v6M14 11v6"/>
</svg>
Löschen
</button>
</form>
</div>
</div>
</li>
+6 -5
View File
@@ -4,18 +4,19 @@
{% block body %}
<div class="card">
<div class="row-between">
<h1 style="margin: 0;">Archiv</h1>
<div class="page-head">
<div>
<h1>Archiv</h1>
<div class="subtitle">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
</div>
<p class="muted" style="margin-top: 0.75rem;">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</p>
{% if mahlzeiten|length == 0 %}
<p class="muted">Noch nichts gegessen – das Archiv ist leer.</p>
<p class="empty">Noch nichts gegessen – das Archiv ist leer.</p>
{% else %}
<ul class="list">
{% for m in mahlzeiten %}
+13 -4
View File
@@ -4,9 +4,16 @@
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
{% block fab %}{% endblock %}
{% block body %}
<div class="card">
<h1 style="margin-top: 0;">{{ titel }}</h1>
<div class="page-head">
<div>
<h1>{{ titel }}</h1>
<div class="subtitle">{{ mahlzeit ? 'Ändere Name, Notiz oder Wochentage.' : 'Ein Gericht, das wieder auf den Plan soll.' }}</div>
</div>
</div>
{% if fehler %}
<div class="flash flash-error">{{ fehler }}</div>
@@ -17,12 +24,14 @@
<div class="field">
<label for="name">Name <span class="req" title="Pflichtfeld">*</span></label>
<input type="text" id="name" name="name" value="{{ werte.name }}" required>
<input type="text" id="name" name="name" value="{{ werte.name }}" required
placeholder="z. B. Spaghetti Bolognese" autofocus
autocomplete="off" enterkeyhint="next">
</div>
<div class="field">
<label for="notiz">Notiz</label>
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …">{{ werte.notiz }}</textarea>
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …" enterkeyhint="done">{{ werte.notiz }}</textarea>
</div>
<div class="field">
@@ -39,7 +48,7 @@
</div>
</div>
<div class="actions">
<div class="actions" style="margin-top: 1.4rem;">
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
</div>
+48 -26
View File
@@ -4,21 +4,23 @@
{% block body %}
<div class="card">
<div class="row-between">
<div class="page-head">
<div>
<h1 style="margin: 0;">Mahlzeiten</h1>
<div class="muted" style="margin-top: 0.2rem;">Planungsliste – per Drag &amp; Drop sortieren</div>
<h1>Mahlzeiten</h1>
<div class="subtitle">Planungsliste – per Drag &amp; Drop sortieren</div>
</div>
<div class="actions">
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_tage') }}">Tagesansicht</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
Neue Mahlzeit
</a>
</div>
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten in der Planung.</p>
<p class="empty">Noch keine Mahlzeiten in der Planung.</p>
{% else %}
<ul class="list sortierbar">
{% for m in mahlzeiten %}
@@ -34,26 +36,46 @@
<script>
(function () {
const liste = document.querySelector('ul.sortierbar');
if (!liste || typeof Sortable === 'undefined') { return; }
if (!liste) { return; }
const url = '{{ path("mahlzeiten_reorder") }}';
Sortable.create(liste, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'item-drag',
onEnd: function () {
const ids = Array.prototype.map.call(
liste.querySelectorAll('li[data-id]'),
function (li) { return Number(li.getAttribute('data-id')); }
);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids }),
}).then(function (antwort) {
if (!antwort.ok) { window.location.reload(); }
}).catch(function () { window.location.reload(); });
},
function speichern() {
const ids = Array.prototype.map.call(
liste.querySelectorAll('li[data-id]'),
function (li) { return Number(li.getAttribute('data-id')); }
);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids }),
}).then(function (antwort) {
if (!antwort.ok) { window.location.reload(); }
}).catch(function () { window.location.reload(); });
}
// Auf/Ab-Buttons (mobil): Element mit Nachbar tauschen, dann speichern.
liste.addEventListener('click', function (ereignis) {
const knopf = ereignis.target.closest('.updown-btn');
if (!knopf) { return; }
const li = knopf.closest('li[data-id]');
if (!li) { return; }
const ziel = knopf.getAttribute('data-dir') === '1'
? li.nextElementSibling
: li.previousElementSibling;
if (!ziel || !ziel.matches('li[data-id]')) { return; }
if (knopf.getAttribute('data-dir') === '1') { li.after(ziel); } else { li.before(ziel); }
speichern();
});
// Drag & Drop (Desktop)
if (typeof Sortable !== 'undefined') {
Sortable.create(liste, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'item-drag',
onEnd: speichern,
});
}
})();
</script>
{% endblock %}
+16 -11
View File
@@ -4,10 +4,10 @@
{% block body %}
<div class="card">
<div class="row-between">
<div class="page-head">
<div>
<h1 style="margin: 0;">Inspiration</h1>
<div class="muted" style="margin-top: 0.2rem;">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
<h1>Inspiration</h1>
<div class="subtitle">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
@@ -18,40 +18,45 @@
{% set alleAusschluss = ausschluss + gezeigte %}
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">
<p class="empty">
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
</p>
{% else %}
<ul class="list">
{% for v in vorschlaege %}
<li class="item" id="vorschlag-{{ v.id }}">
<div>
<div class="item-name">
<strong>{{ v.name }}</strong>
{% if v.notiz != '' %}
<div class="muted">{{ v.notiz }}</div>
<div class="notiz">{{ v.notiz }}</div>
{% endif %}
<span class="status">
<div class="vorschlag-meta">
{% if v.gegessenAm %}
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
{% else %}
aus dem Archiv
{% endif %}
· {{ v.auftraete }}× in der Küche
</span>
· <b>{{ v.auftraete }}</b>× in der Küche
</div>
</div>
<div class="actions">
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
{% for id in alleAusschluss %}
<input type="hidden" name="ausschluss[]" value="{{ id }}">
{% endfor %}
<button type="submit" class="btn">Übernehmen</button>
<button type="submit" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"/>
</svg>
Übernehmen
</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% if mehrVerfuegbar %}
<p style="margin: 1rem 0 0;">
<p class="muted" style="margin: 1.1rem 0 0; text-align: center;">
<a class="btn btn-link"
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
</p>
+5 -5
View File
@@ -4,10 +4,10 @@
{% block body %}
<div class="card">
<div class="row-between">
<div class="page-head">
<div>
<h1 style="margin: 0;">Tagesansicht</h1>
<div class="muted" style="margin-top: 0.2rem;">Alle Mahlzeiten nach Wochentag</div>
<h1>Tagesansicht</h1>
<div class="subtitle">Alle Mahlzeiten nach Wochentag</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
@@ -16,10 +16,10 @@
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
<p class="empty">Noch keine Mahlzeiten angelegt.</p>
{% else %}
{% for tag, mahlzeiten in tage %}
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
<h2 data-count="{{ mahlzeiten|length }}" {% if tag == heute %}data-heute="1"{% endif %}>{{ tag }}</h2>
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}