chore: release v0.22.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.22.0] - 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipes module: create, edit, duplicate, and delete reusable recipes with title, notes, a recipe link, and a per-ingredient category. Accessible via the new `/recipes` route and nav entry.
|
||||||
|
- "Add to meal plan" action on recipe cards navigates to Meals and pre-fills the modal with the selected recipe.
|
||||||
|
- Meals modal: select a saved recipe to auto-fill title, notes, URL, and ingredients; scale ingredient quantities by a numeric factor; save the current meal as a new recipe in one click.
|
||||||
|
- `GET/POST /api/v1/recipes`, `PUT/DELETE /api/v1/recipes/:id` REST endpoints with full validation and ingredient sync.
|
||||||
|
- Migration 13: `recipes` and `recipe_ingredients` tables; `recipe_id` FK column on `meals`.
|
||||||
|
|
||||||
## [0.21.1] - 2026-04-21
|
## [0.21.1] - 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.21.1",
|
"version": "0.22.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.21.1",
|
"version": "0.22.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.21.1",
|
"version": "0.22.0",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+27
-27
@@ -44,7 +44,7 @@
|
|||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"quickActions": "Schnellaktionen",
|
"quickActions": "Schnellaktionen",
|
||||||
"more": "Mehr",
|
"more": "Mehr",
|
||||||
"recipes": "Recipes"
|
"recipes": "Rezepte"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "Suche",
|
"title": "Suche",
|
||||||
@@ -258,10 +258,10 @@
|
|||||||
"recipeUrlLabel": "Rezept-Link (optional)",
|
"recipeUrlLabel": "Rezept-Link (optional)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Rezept öffnen",
|
"openRecipe": "Rezept öffnen",
|
||||||
"savedRecipeLabel": "Saved recipe",
|
"savedRecipeLabel": "Gespeichertes Rezept",
|
||||||
"savedRecipePlaceholder": "Select recipe",
|
"savedRecipePlaceholder": "Rezept auswählen",
|
||||||
"saveAsRecipe": "Save as recipe",
|
"saveAsRecipe": "Als Rezept speichern",
|
||||||
"recipeScaleLabel": "Scale ingredients"
|
"recipeScaleLabel": "Zutaten skalieren"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
@@ -683,27 +683,27 @@
|
|||||||
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Recipes",
|
"title": "Rezepte",
|
||||||
"addRecipe": "Add recipe",
|
"addRecipe": "Rezept hinzufügen",
|
||||||
"editRecipe": "Edit recipe",
|
"editRecipe": "Rezept bearbeiten",
|
||||||
"emptyTitle": "No recipes yet",
|
"emptyTitle": "Noch keine Rezepte",
|
||||||
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
"emptyDescription": "Speichere deine Lieblingsrezepte und nutze sie für die Essensplanung.",
|
||||||
"titleLabel": "Title *",
|
"titleLabel": "Titel *",
|
||||||
"titlePlaceholder": "e.g. Pasta Carbonara",
|
"titlePlaceholder": "z. B. Pasta Carbonara",
|
||||||
"notesLabel": "Notes",
|
"notesLabel": "Notizen",
|
||||||
"notesPlaceholder": "Optional...",
|
"notesPlaceholder": "Optional…",
|
||||||
"urlLabel": "Recipe link",
|
"urlLabel": "Rezept-Link",
|
||||||
"urlPlaceholder": "https://...",
|
"urlPlaceholder": "https://…",
|
||||||
"ingredientsLabel": "Ingredients",
|
"ingredientsLabel": "Zutaten",
|
||||||
"addToMeals": "Add to meal plan",
|
"addToMeals": "In Essensplan übernehmen",
|
||||||
"openLink": "Open recipe link",
|
"openLink": "Rezept-Link öffnen",
|
||||||
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
"deleteConfirm": "Rezept „{{title}}" löschen?",
|
||||||
"created": "Recipe saved.",
|
"created": "Rezept gespeichert.",
|
||||||
"updated": "Recipe updated.",
|
"updated": "Rezept aktualisiert.",
|
||||||
"deleted": "Recipe deleted.",
|
"deleted": "Rezept gelöscht.",
|
||||||
"titleRequired": "Title is required",
|
"titleRequired": "Titel ist erforderlich.",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplizieren",
|
||||||
"duplicated": "Recipe duplicated.",
|
"duplicated": "Rezept dupliziert.",
|
||||||
"copySuffix": "copy"
|
"copySuffix": "Kopie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-28
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
|
||||||
import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js';
|
||||||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||||
|
|
||||||
@@ -211,32 +210,63 @@ function renderRecipeList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recipeIngredientRowHTML(name, qty, category = DEFAULT_CATEGORY_NAME) {
|
function buildIngredientRow(name, qty, category = DEFAULT_CATEGORY_NAME) {
|
||||||
const categories = mealCategories();
|
const categories = mealCategories();
|
||||||
const resolvedCategory = categories.some((c) => c.name === category)
|
const resolvedCategory = categories.some((c) => c.name === category)
|
||||||
? category
|
? category
|
||||||
: (categories[0]?.name ?? DEFAULT_CATEGORY_NAME);
|
: (categories[0]?.name ?? DEFAULT_CATEGORY_NAME);
|
||||||
const catOptions = categories.length
|
|
||||||
? categories.map((c) => `<option value="${esc(c.name)}" ${c.name === resolvedCategory ? 'selected' : ''}>${esc(categoryLabel(c.name))}</option>`).join('')
|
|
||||||
: `<option value="${DEFAULT_CATEGORY_NAME}" selected>${t('meals.ingredientCategoryDefault')}</option>`;
|
|
||||||
|
|
||||||
return `
|
const row = document.createElement('div');
|
||||||
<div class="recipe-ingredient-row">
|
row.className = 'recipe-ingredient-row';
|
||||||
<input type="text" class="form-input recipe-ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
|
|
||||||
<input type="text" class="form-input recipe-ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
|
const nameInput = document.createElement('input');
|
||||||
<select class="form-input recipe-ingredient-row__cat" aria-label="${t('meals.ingredientCategoryLabel')}">${catOptions}</select>
|
nameInput.type = 'text';
|
||||||
<button class="recipe-ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
nameInput.className = 'form-input recipe-ingredient-row__name';
|
||||||
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
nameInput.placeholder = t('meals.ingredientNamePlaceholder');
|
||||||
</button>
|
nameInput.value = name;
|
||||||
</div>
|
|
||||||
`;
|
const qtyInput = document.createElement('input');
|
||||||
|
qtyInput.type = 'text';
|
||||||
|
qtyInput.className = 'form-input recipe-ingredient-row__qty';
|
||||||
|
qtyInput.placeholder = t('meals.ingredientQtyPlaceholder');
|
||||||
|
qtyInput.value = qty;
|
||||||
|
|
||||||
|
const catSelect = document.createElement('select');
|
||||||
|
catSelect.className = 'form-input recipe-ingredient-row__cat';
|
||||||
|
catSelect.setAttribute('aria-label', t('meals.ingredientCategoryLabel'));
|
||||||
|
if (categories.length) {
|
||||||
|
for (const c of categories) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.name;
|
||||||
|
opt.textContent = categoryLabel(c.name);
|
||||||
|
if (c.name === resolvedCategory) opt.selected = true;
|
||||||
|
catSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = DEFAULT_CATEGORY_NAME;
|
||||||
|
opt.textContent = t('meals.ingredientCategoryDefault');
|
||||||
|
opt.selected = true;
|
||||||
|
catSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'recipe-ingredient-row__remove';
|
||||||
|
removeBtn.dataset.action = 'remove-ingredient';
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.setAttribute('aria-label', t('meals.removeIngredient'));
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.dataset.lucide = 'x';
|
||||||
|
icon.style.cssText = 'width:14px;height:14px;';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
removeBtn.appendChild(icon);
|
||||||
|
|
||||||
|
row.append(nameInput, qtyInput, catSelect, removeBtn);
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRecipeModal(mode, recipe = null) {
|
function openRecipeModal(mode, recipe = null) {
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const ingredientRows = isEdit && recipe.ingredients?.length
|
|
||||||
? recipe.ingredients.map((i) => recipeIngredientRowHTML(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME)).join('')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'),
|
title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'),
|
||||||
@@ -244,19 +274,19 @@ function openRecipeModal(mode, recipe = null) {
|
|||||||
content: `
|
content: `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="recipe-title">${t('recipes.titleLabel')}</label>
|
<label class="form-label" for="recipe-title">${t('recipes.titleLabel')}</label>
|
||||||
<input id="recipe-title" class="form-input" type="text" value="${esc(isEdit ? recipe.title : '')}" placeholder="${t('recipes.titlePlaceholder')}">
|
<input id="recipe-title" class="form-input" type="text" placeholder="${t('recipes.titlePlaceholder')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="recipe-notes">${t('recipes.notesLabel')}</label>
|
<label class="form-label" for="recipe-notes">${t('recipes.notesLabel')}</label>
|
||||||
<textarea id="recipe-notes" class="form-input" rows="3" placeholder="${t('recipes.notesPlaceholder')}">${esc(isEdit && recipe.notes ? recipe.notes : '')}</textarea>
|
<textarea id="recipe-notes" class="form-input" rows="3" placeholder="${t('recipes.notesPlaceholder')}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
||||||
<input id="recipe-url" class="form-input" type="url" value="${esc(isEdit && recipe.recipe_url ? recipe.recipe_url : '')}" placeholder="${t('recipes.urlPlaceholder')}">
|
<input id="recipe-url" class="form-input" type="url" placeholder="${t('recipes.urlPlaceholder')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
||||||
<div class="recipe-ingredient-list" id="recipe-ingredient-list">${ingredientRows}</div>
|
<div class="recipe-ingredient-list" id="recipe-ingredient-list"></div>
|
||||||
<button class="btn btn--secondary recipe-add-ingredient" type="button" id="recipe-add-ingredient">${t('meals.addIngredient')}</button>
|
<button class="btn btn--secondary recipe-add-ingredient" type="button" id="recipe-add-ingredient">${t('meals.addIngredient')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
@@ -265,12 +295,19 @@ function openRecipeModal(mode, recipe = null) {
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
|
panel.querySelector('#recipe-title').value = isEdit ? recipe.title : '';
|
||||||
|
panel.querySelector('#recipe-notes').value = isEdit && recipe.notes ? recipe.notes : '';
|
||||||
|
panel.querySelector('#recipe-url').value = isEdit && recipe.recipe_url ? recipe.recipe_url : '';
|
||||||
|
|
||||||
const ingList = panel.querySelector('#recipe-ingredient-list');
|
const ingList = panel.querySelector('#recipe-ingredient-list');
|
||||||
|
if (isEdit && recipe.ingredients?.length) {
|
||||||
|
for (const i of recipe.ingredients) {
|
||||||
|
ingList.appendChild(buildIngredientRow(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
panel.querySelector('#recipe-add-ingredient')?.addEventListener('click', () => {
|
panel.querySelector('#recipe-add-ingredient')?.addEventListener('click', () => {
|
||||||
const tmp = document.createElement('div');
|
ingList.appendChild(buildIngredientRow('', '', null));
|
||||||
tmp.innerHTML = recipeIngredientRowHTML('', '', null);
|
|
||||||
const row = tmp.firstElementChild;
|
|
||||||
ingList.appendChild(row);
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -362,8 +399,8 @@ async function duplicateRecipe(recipe) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
const res = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||||
await loadRecipes();
|
state.recipes.push(res.data);
|
||||||
renderRecipeList();
|
renderRecipeList();
|
||||||
window.oikos?.showToast(t('recipes.duplicated'), 'success');
|
window.oikos?.showToast(t('recipes.duplicated'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+1
-1
@@ -582,7 +582,7 @@ function navItems() {
|
|||||||
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
||||||
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
||||||
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
||||||
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-open-text' },
|
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
|
||||||
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
||||||
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||||
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ const MIGRATIONS_SQL = {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
|
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
|
||||||
`,
|
`,
|
||||||
12: `
|
12: `
|
||||||
|
DROP INDEX IF EXISTS idx_calendar_sub_extid;
|
||||||
|
CREATE UNIQUE INDEX idx_calendar_sub_extid
|
||||||
|
ON calendar_events (subscription_id, external_calendar_id);
|
||||||
|
`,
|
||||||
|
13: `
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ router.put('/:id', (req, res) => {
|
|||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
|
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
|
||||||
|
|
||||||
const existing = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id);
|
const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
||||||
|
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
||||||
|
|
||||||
const { ingredients = [] } = req.body;
|
const { ingredients = [] } = req.body;
|
||||||
|
|
||||||
@@ -154,8 +155,11 @@ router.put('/:id', (req, res) => {
|
|||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
const existing = num(id, 'Rezept-ID', { required: true });
|
if (!id) return res.status(400).json({ error: 'Ungültige Rezept-ID.', code: 400 });
|
||||||
if (existing.error) return res.status(400).json({ error: existing.error, code: 400 });
|
|
||||||
|
const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden.', code: 404 });
|
||||||
|
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
||||||
|
|
||||||
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
||||||
if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
||||||
|
|||||||
Reference in New Issue
Block a user