Add phase 2: recipes with visibility, photos, search and favourites

- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:37:13 +01:00
co-authored by Claude Opus 5.5
parent ef9deaeff7
commit 6f7e4f92ca
41 changed files with 1870 additions and 37 deletions
+70
View File
@@ -183,3 +183,73 @@ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); ou
.copyrow { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
.copyrow input { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 0.9rem; }
/* --- Recipes ----------------------------------------------------------------- */
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.search { margin-bottom: 1rem; }
.search-row { display: flex; gap: 0.5rem; }
.search-row input { flex: 1; min-width: 0; }
input[type="search"] {
width: 100%; min-height: var(--tap); padding: 0.6rem 0.75rem; font: inherit; color: var(--text);
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
}
.filters { margin-top: 0.5rem; }
.filters summary { cursor: pointer; color: var(--accent); min-height: 36px; display: flex; align-items: center; }
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 1rem; }
.chip {
display: inline-block; padding: 0.3rem 0.75rem; border-radius: 999px; font-size: 0.9rem;
background: var(--surface); border: 1px solid var(--border); color: var(--text); text-decoration: none;
}
.chip.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
.recipe-grid { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 640px) { .recipe-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.recipe-card {
display: block; height: 100%; overflow: hidden; text-decoration: none; color: var(--text);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.recipe-card img, .recipe-card .placeholder { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.recipe-card .placeholder { display: grid; place-items: center; font-size: 2.5rem; background: var(--border); }
.recipe-card-body { padding: 0.6rem 0.75rem 0.75rem; display: flex; flex-direction: column; gap: 0.2rem; line-height: 1.3; }
.recipe-card-body .muted { font-size: 0.82rem; }
.empty { text-align: center; }
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; gap: 0.5rem; }
.gallery { display: flex; gap: 0.5rem; overflow-x: auto; scroll-snap-type: x mandatory; margin: 0 -1rem 1rem; padding: 0 1rem; }
.gallery a { flex: 0 0 100%; scroll-snap-align: center; }
.gallery img { width: 100%; height: auto; max-height: 60vh; object-fit: cover; border-radius: var(--radius); display: block; }
.gallery:has(a:nth-child(2)) a { flex-basis: 88%; }
.recipe-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.recipe-actions form { margin: 0; }
.lead { font-size: 1.05rem; }
.facts { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0 0 1rem; }
.facts .muted { display: block; font-size: 0.8rem; }
.section-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.5rem; }
.section-head h2 { margin: 0; }
h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); }
.ingredients { list-style: none; padding: 0; margin: 0.5rem 0 0; }
.ingredients li { display: flex; gap: 0.75rem; padding: 0.45rem 0; border-bottom: 1px solid var(--border); }
.ingredients li:last-child { border-bottom: 0; }
.ingredients .amount { flex: 0 0 6.5rem; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
.steps { padding-left: 1.4rem; margin: 0.5rem 0 0; }
.steps li { margin-bottom: 0.9rem; padding-left: 0.25rem; }
.notes { border-left: 4px solid var(--accent); }
.servings { display: flex; align-items: center; gap: 0.4rem; margin: 0; }
.servings label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; margin: 0; }
.servings input { width: 4rem; min-height: 40px; text-align: center; padding: 0.3rem; }
.step-btn {
display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%;
border: 1px solid var(--accent); color: var(--accent); text-decoration: none; font-size: 1.3rem; font-weight: 600;
}
.step-btn.disabled { opacity: 0.35; }
.small-link { font-size: 0.85rem; }
.recipe-form textarea { font-size: 16px; line-height: 1.45; }
.recipe-form ul { list-style: none; padding: 0; margin: 0; }
.recipe-form ul label { display: flex; align-items: center; gap: 0.5rem; font-weight: 400; min-height: var(--tap); }
.form-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.photo-admin { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.photo-admin img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; margin-bottom: 0.4rem; }