Phase 4: HTMX frontend with dark palette

- 4 pages: Pantry, Recipes, Shopping List, Cook Log
- HTMX-powered: add/delete pantry items, toggle shopping, generate smart list
- Custom 13-colour palette from Lospec (dark bg, yellow accent)
- Mobile-responsive
- Whitenoise for static files in production
- All routes under /app/
- API (/api/) stays internal, frontend (/app/) for browser use
This commit is contained in:
Caine
2026-04-02 23:08:43 +01:00
parent 75e3256eae
commit 2a45be70e2
16 changed files with 1078 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Kitchen{% endblock %}</title>
<script src="{% static 'kitchen/htmx.min.js' %}"></script>
<style>
:root {
--bg-dark: #15131c;
--navy: #0e0e5b;
--teal-dark: #325664;
--red-dark: #aa1010;
--orange-burnt: #bd6611;
--sage: #658d89;
--red-bright: #f01111;
--yellow: #f9df11;
--orange-warm: #fa9f45;
--teal-light: #87d1d1;
--grey-light: #babcc4;
--peach: #fdceb0;
--cream: #f7fdc7;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-dark);
color: var(--grey-light);
min-height: 100vh;
line-height: 1.5;
}
/* --- NAV --- */
nav {
background: var(--navy);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
border-bottom: 2px solid var(--teal-dark);
position: sticky;
top: 0;
z-index: 100;
}
nav .logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--yellow);
text-decoration: none;
letter-spacing: 0.5px;
}
nav .links {
display: flex;
gap: 0.25rem;
}
nav a {
color: var(--grey-light);
text-decoration: none;
padding: 0.4rem 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
transition: all 0.15s;
}
nav a:hover, nav a.active {
background: var(--teal-dark);
color: var(--cream);
}
/* --- LAYOUT --- */
.container {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
h1 {
color: var(--yellow);
font-size: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
h2 {
color: var(--teal-light);
font-size: 1.15rem;
margin: 1.5rem 0 0.75rem;
font-weight: 600;
}
/* --- CARDS --- */
.card {
background: rgba(50, 86, 100, 0.15);
border: 1px solid var(--teal-dark);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
color: var(--cream);
font-weight: 600;
font-size: 1rem;
}
/* --- TABLES --- */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th {
text-align: left;
color: var(--teal-light);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--teal-dark);
}
td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(50, 86, 100, 0.3);
font-size: 0.9rem;
}
tr:hover {
background: rgba(50, 86, 100, 0.15);
}
/* --- BADGES --- */
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-fridge { background: var(--teal-dark); color: var(--teal-light); }
.badge-freezer { background: var(--navy); color: var(--teal-light); }
.badge-cupboard { background: rgba(189, 102, 17, 0.25); color: var(--orange-warm); }
.badge-ok { background: rgba(101, 141, 137, 0.25); color: var(--sage); }
.badge-expiring { background: rgba(250, 159, 69, 0.25); color: var(--orange-warm); }
.badge-expired { background: rgba(240, 17, 17, 0.25); color: var(--red-bright); }
.badge-ready { background: rgba(101, 141, 137, 0.3); color: var(--sage); }
.badge-partial { background: rgba(249, 223, 17, 0.2); color: var(--yellow); }
.badge-missing { background: rgba(170, 16, 16, 0.25); color: var(--red-bright); }
/* --- RATING --- */
.stars { color: var(--yellow); letter-spacing: 1px; }
.stars-empty { color: var(--teal-dark); }
/* --- BUTTONS --- */
.btn {
display: inline-block;
padding: 0.4rem 0.9rem;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.btn-primary {
background: var(--yellow);
color: var(--bg-dark);
}
.btn-primary:hover { background: var(--cream); }
.btn-secondary {
background: var(--teal-dark);
color: var(--grey-light);
}
.btn-secondary:hover { background: var(--sage); color: var(--cream); }
.btn-danger {
background: transparent;
color: var(--red-dark);
border: 1px solid var(--red-dark);
}
.btn-danger:hover { background: var(--red-dark); color: var(--cream); }
.btn-sm {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
}
/* --- FORMS --- */
input, select, textarea {
background: rgba(14, 14, 91, 0.3);
border: 1px solid var(--teal-dark);
color: var(--cream);
padding: 0.4rem 0.6rem;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--yellow);
box-shadow: 0 0 0 2px rgba(249, 223, 17, 0.15);
}
input::placeholder { color: var(--sage); }
label {
display: block;
color: var(--teal-light);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.form-group { margin-bottom: 0.75rem; }
.form-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.form-row .form-group { flex: 1; min-width: 120px; }
/* --- SLOT PICKER --- */
.slot {
margin-bottom: 1rem;
}
.slot-name {
color: var(--orange-warm);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.4rem;
}
.slot-options {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.slot-option {
padding: 0.3rem 0.7rem;
border: 1px solid var(--teal-dark);
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
background: transparent;
color: var(--grey-light);
}
.slot-option:hover {
border-color: var(--yellow);
color: var(--cream);
}
.slot-option.selected {
background: rgba(249, 223, 17, 0.15);
border-color: var(--yellow);
color: var(--yellow);
}
.slot-option.unavailable {
opacity: 0.4;
border-style: dashed;
}
/* --- SHOPPING LIST --- */
.shop-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(50, 86, 100, 0.2);
}
.shop-item input[type="checkbox"] {
accent-color: var(--yellow);
width: 1.1rem;
height: 1.1rem;
}
.shop-item.checked .shop-name {
text-decoration: line-through;
color: var(--sage);
opacity: 0.6;
}
.shop-section {
color: var(--orange-warm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.75rem 0 0.25rem;
}
/* --- COOK LOG --- */
.log-entry {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(50, 86, 100, 0.2);
}
.log-date {
color: var(--sage);
font-size: 0.8rem;
min-width: 5rem;
}
.log-notes {
color: var(--grey-light);
font-size: 0.85rem;
font-style: italic;
margin-top: 0.25rem;
}
/* --- EMPTY STATE --- */
.empty {
text-align: center;
padding: 3rem;
color: var(--sage);
}
.empty-icon { font-size: 2rem; margin-bottom: 0.5rem; }
/* --- HTMX LOADING --- */
.htmx-indicator {
display: none;
color: var(--yellow);
font-size: 0.8rem;
}
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
/* --- TOAST --- */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
z-index: 1000;
animation: fadeIn 0.2s, fadeOut 0.3s 2.5s forwards;
}
.toast-success { background: var(--sage); color: var(--bg-dark); }
.toast-error { background: var(--red-dark); color: var(--cream); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
/* --- RESPONSIVE --- */
@media (max-width: 640px) {
nav { padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; }
.container { padding: 1rem; }
.form-row { flex-direction: column; }
.form-row .form-group { min-width: 100%; }
td, th { padding: 0.35rem 0.5rem; font-size: 0.8rem; }
}
</style>
</head>
<body>
<nav>
<a href="{% url 'app-pantry' %}" class="logo">🍳 Kitchen</a>
<div class="links">
<a href="{% url 'app-pantry' %}" {% if active_tab == 'pantry' %}class="active"{% endif %}>Pantry</a>
<a href="{% url 'app-recipes' %}" {% if active_tab == 'recipes' %}class="active"{% endif %}>Recipes</a>
<a href="{% url 'app-shopping' %}" {% if active_tab == 'shopping' %}class="active"{% endif %}>Shopping</a>
<a href="{% url 'app-log' %}" {% if active_tab == 'log' %}class="active"{% endif %}>Cook Log</a>
</div>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "kitchen/base.html" %}
{% block title %}Cook Log — Kitchen{% endblock %}
{% block content %}
<h1>Cook Log</h1>
<div id="log-entries">
{% include "kitchen/partials/cook_log.html" %}
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "kitchen/base.html" %}
{% block title %}Pantry — Kitchen{% endblock %}
{% block content %}
<h1>Pantry</h1>
<!-- Add item form -->
<div class="card" style="margin-bottom: 1.5rem;">
<form hx-post="{% url 'app-pantry-add' %}" hx-target="#pantry-items" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
{% csrf_token %}
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label for="ingredient_name">Ingredient</label>
<input type="text" id="ingredient_name" name="ingredient_name" placeholder="e.g. chicken thighs" required style="width: 100%;">
</div>
<div class="form-group">
<label for="quantity">Qty</label>
<input type="number" id="quantity" name="quantity" step="0.01" placeholder="2" required style="width: 100%;">
</div>
<div class="form-group">
<label for="unit">Unit</label>
<input type="text" id="unit" name="unit" placeholder="items" required style="width: 100%;">
</div>
<div class="form-group">
<label for="location">Location</label>
<select id="location" name="location" style="width: 100%;">
<option value="fridge">Fridge</option>
<option value="freezer">Freezer</option>
<option value="cupboard">Cupboard</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn btn-primary">Add</button>
</div>
</div>
</form>
</div>
<!-- Pantry items -->
<div id="pantry-items">
{% include "kitchen/partials/pantry_table.html" %}
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% for entry in entries %}
<div class="log-entry">
<div class="log-date">{{ entry.date|date:"j M" }}</div>
<div style="flex: 1;">
<div>
<span class="card-title">{{ entry.recipe_name }}</span>
{% if entry.rating %}
<span class="stars" style="margin-left: 0.5rem;">
{% for i in "12345" %}{% if forloop.counter <= entry.rating %}★{% else %}<span class="stars-empty"></span>{% endif %}{% endfor %}
</span>
{% endif %}
</div>
{% if entry.slot_choices %}
<div style="margin-top: 0.25rem;">
{% for slot, choice in entry.slot_choices.items %}
<span class="badge badge-cupboard">{{ choice }}</span>
{% endfor %}
</div>
{% endif %}
{% if entry.notes %}
<div class="log-notes">{{ entry.notes }}</div>
{% endif %}
<div style="color: var(--sage); font-size: 0.75rem; margin-top: 0.25rem;">{{ entry.servings }} serving{{ entry.servings|pluralize }}</div>
</div>
</div>
{% empty %}
<div class="empty">
<div class="empty-icon">📝</div>
<p>No cooks logged yet.</p>
</div>
{% endfor %}
@@ -0,0 +1,82 @@
{% if fridge_items or freezer_items or cupboard_items %}
{% if fridge_items %}
<h2>🧊 Fridge</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Expiry</th><th></th></tr></thead>
<tbody>
{% for item in fridge_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td>
{% if item.is_expired %}
<span class="badge badge-expired">Expired {{ item.expiry_date }}</span>
{% elif item.expiring_soon %}
<span class="badge badge-expiring">{{ item.expiry_date }}</span>
{% elif item.expiry_date %}
<span class="badge badge-ok">{{ item.expiry_date }}</span>
{% else %}
<span style="color: var(--sage);"></span>
{% endif %}
</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if freezer_items %}
<h2>❄️ Freezer</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{% for item in freezer_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if cupboard_items %}
<h2>🗄️ Cupboard</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{% for item in cupboard_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}{% if item.is_staple %} <span style="color: var(--yellow); font-size: 0.7rem;">STAPLE</span>{% endif %}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<div class="empty">
<div class="empty-icon">🥡</div>
<p>Pantry's empty. Add some items above.</p>
</div>
{% endif %}
@@ -0,0 +1,63 @@
{% for recipe in recipes %}
<div class="card">
<div class="card-header">
<span class="card-title">
{% if recipe.status == 'ready' %}
<span class="badge badge-ready">✅ Ready</span>
{% elif recipe.status == 'partial' %}
<span class="badge badge-partial">⚠️ Partial</span>
{% else %}
<span class="badge badge-missing">❌ Missing</span>
{% endif %}
{{ recipe.name }}
</span>
{% if recipe.type == 'meta_recipe' %}
<span style="color: var(--sage); font-size: 0.8rem;">{{ recipe.gear }}</span>
{% endif %}
</div>
{% if recipe.type == 'meta_recipe' %}
{% for slot in recipe.slots %}
<div class="slot">
<div class="slot-name">
{{ slot.name }}
{% if not slot.required %}<span style="color: var(--sage); font-size: 0.7rem;">(optional)</span>{% endif %}
</div>
<div class="slot-options">
{% for opt in slot.available_options %}
<span class="slot-option" title="Have: {{ opt.have }}{% if opt.notes %} — {{ opt.notes }}{% endif %}">
{{ opt.ingredient }}
</span>
{% endfor %}
{% for opt in slot.missing_options %}
<span class="slot-option unavailable" title="Need: {{ opt.needed }}{% if opt.notes %} — {{ opt.notes }}{% endif %}">
{{ opt.ingredient }}
</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% if recipe.base_missing %}
<div style="margin-top: 0.5rem;">
<span style="color: var(--red-bright); font-size: 0.8rem;">
Missing base: {% for b in recipe.base_missing %}{{ b.ingredient }} ({{ b.needed }}){% if not forloop.last %}, {% endif %}{% endfor %}
</span>
</div>
{% endif %}
{% endif %}
{% if recipe.warnings %}
<div style="margin-top: 0.5rem;">
{% for w in recipe.warnings %}
<span style="color: var(--orange-warm); font-size: 0.8rem;">⚠ {{ w }}</span><br>
{% endfor %}
</div>
{% endif %}
</div>
{% empty %}
<div class="empty">
<div class="empty-icon">📖</div>
<p>No recipes yet.</p>
</div>
{% endfor %}
@@ -0,0 +1,25 @@
{% regroup items by section as sections %}
{% for section in sections %}
<div class="shop-section">{{ section.grouper|default:"Other" }}</div>
{% for item in section.list %}
<div class="shop-item {% if item.checked %}checked{% endif %}">
<input type="checkbox"
{% if item.checked %}checked{% endif %}
hx-post="{% url 'app-shopping-toggle' item.id %}"
hx-target="#shopping-items"
hx-swap="innerHTML">
<span class="shop-name">
{{ item.name }}
{% if item.quantity %}<span style="color: var(--sage);">× {{ item.quantity|floatformat:0 }} {{ item.unit }}</span>{% endif %}
</span>
{% if item.reason %}
<span style="color: var(--sage); font-size: 0.75rem; margin-left: auto;">{{ item.reason }}</span>
{% endif %}
</div>
{% endfor %}
{% empty %}
<div class="empty">
<div class="empty-icon">🛒</div>
<p>Shopping list is empty. Hit "Generate Smart List" to get suggestions.</p>
</div>
{% endfor %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "kitchen/base.html" %}
{% block title %}Recipes — Kitchen{% endblock %}
{% block content %}
<h1>What Can I Cook?</h1>
<div id="recipe-list">
{% include "kitchen/partials/recipe_list.html" %}
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "kitchen/base.html" %}
{% block title %}Shopping List — Kitchen{% endblock %}
{% block content %}
<h1>Shopping List</h1>
<div style="display: flex; gap: 0.75rem; margin-bottom: 1.5rem;">
<button class="btn btn-primary"
hx-post="{% url 'app-shopping-generate' %}"
hx-target="#shopping-items"
hx-swap="innerHTML">
🔄 Generate Smart List
</button>
<button class="btn btn-secondary"
hx-post="{% url 'app-shopping-clear' %}"
hx-target="#shopping-items"
hx-swap="innerHTML"
hx-confirm="Clear checked items?">
Clear Checked
</button>
</div>
<div id="shopping-items">
{% include "kitchen/partials/shopping_list.html" %}
</div>
{% endblock %}