mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
feat: improve readability of ingredients list (#2502)
* feat: improve readability of notes in ingredients list Makes the notes in the ingredients list more readable by making them slightly opaque. This creates a better visual separation between the notes and the rest of the ingredient. * Use server display if available * Move note to newline and make quantity more distinct * Use safeMarkdown for shopping list * Use component * Wrap unit in accent color * Update RecipeIngredientListItem to set food in bold
This commit is contained in:
parent
2151451634
commit
50a92c165c
@ -114,7 +114,10 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
/>
|
/>
|
||||||
<v-list-item-content :key="ingredientData.ingredient.quantity">
|
<v-list-item-content :key="ingredientData.ingredient.quantity">
|
||||||
<SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientData.display" />
|
<RecipeIngredientListItem
|
||||||
|
:ingredient="ingredientData.ingredient"
|
||||||
|
:disable-amount="ingredientData.disableAmount"
|
||||||
|
:scale="recipeScale" />
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-card>
|
</v-card>
|
||||||
@ -168,13 +171,13 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
|
import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
|
||||||
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||||
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
|
||||||
import { ShoppingListSummary } from "~/lib/api/types/group";
|
import { ShoppingListSummary } from "~/lib/api/types/group";
|
||||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||||
@ -203,7 +206,8 @@ export default defineComponent({
|
|||||||
components: {
|
components: {
|
||||||
RecipeDialogPrintPreferences,
|
RecipeDialogPrintPreferences,
|
||||||
RecipeDialogShare,
|
RecipeDialogShare,
|
||||||
},
|
RecipeIngredientListItem
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
useItems: {
|
useItems: {
|
||||||
type: Object as () => ContextMenuIncludes,
|
type: Object as () => ContextMenuIncludes,
|
||||||
@ -384,7 +388,7 @@ export default defineComponent({
|
|||||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||||
const selectedShoppingList = ref<ShoppingListSummary>();
|
const selectedShoppingList = ref<ShoppingListSummary>();
|
||||||
const recipeRef = ref<Recipe>(props.recipe);
|
const recipeRef = ref<Recipe>(props.recipe);
|
||||||
const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient; display: string }[]>([]);
|
const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient, disableAmount: boolean }[]>([]);
|
||||||
|
|
||||||
async function getShoppingLists() {
|
async function getShoppingLists() {
|
||||||
const { data } = await api.shopping.lists.getAll();
|
const { data } = await api.shopping.lists.getAll();
|
||||||
@ -411,7 +415,7 @@ export default defineComponent({
|
|||||||
return {
|
return {
|
||||||
checked: true,
|
checked: true,
|
||||||
ingredient,
|
ingredient,
|
||||||
display: parseIngredientText(ingredient, recipeRef.value?.settings?.disableAmount || false, props.recipeScale),
|
disableAmount: recipeRef.value.settings?.disableAmount || false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
|
||||||
|
<SafeMarkdown v-if="quantity" class="d-inline" :source="quantity" />
|
||||||
|
<template v-if="unit">{{ unit }} </template>
|
||||||
|
<SafeMarkdown v-if="note && !name" class="text-bold d-inline" :source="note" />
|
||||||
|
<template v-else>
|
||||||
|
<SafeMarkdown v-if="name" class="text-bold d-inline" :source="name" />
|
||||||
|
<SafeMarkdown v-if="note" class="note" :source="note" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "@nuxtjs/composition-api";
|
||||||
|
import { RecipeIngredient } from "~/lib/api/types/group";
|
||||||
|
import { useParsedIngredientText } from "~/composables/recipes";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
ingredient: {
|
||||||
|
type: Object as () => RecipeIngredient,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disableAmount: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const parsed = useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.ingredient-item {
|
||||||
|
.d-inline {
|
||||||
|
& > p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
line-height: 0.8em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
@ -11,7 +11,7 @@
|
|||||||
<v-list-item dense @click="toggleChecked(index)">
|
<v-list-item dense @click="toggleChecked(index)">
|
||||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
||||||
<v-list-item-content :key="ingredient.quantity">
|
<v-list-item-content :key="ingredient.quantity">
|
||||||
<SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" />
|
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
@ -21,12 +21,12 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
||||||
// @ts-ignore vue-markdown has no types
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {},
|
components: { RecipeIngredientListItem },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: Array as () => RecipeIngredient[],
|
type: Array as () => RecipeIngredient[],
|
||||||
@ -52,7 +52,11 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ingredientCopyText = computed(() => {
|
const ingredientCopyText = computed(() => {
|
||||||
return ingredientDisplay.value.join("\n");
|
return props.value
|
||||||
|
.map((ingredient) => {
|
||||||
|
return `${parseIngredientText(ingredient, props.disableAmount, props.scale)}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleChecked(index: number) {
|
function toggleChecked(index: number) {
|
||||||
@ -61,16 +65,8 @@ export default defineComponent({
|
|||||||
state.checked.splice(index, 1, !state.checked[index]);
|
state.checked.splice(index, 1, !state.checked[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingredientDisplay = computed(() => {
|
|
||||||
return props.value.map((ingredient) => {
|
|
||||||
return `${parseIngredientText(ingredient, props.disableAmount, props.scale)}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ingredientDisplay,
|
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
parseIngredientText,
|
|
||||||
ingredientCopyText,
|
ingredientCopyText,
|
||||||
toggleChecked,
|
toggleChecked,
|
||||||
};
|
};
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<div :class="listItem.checked ? 'strike-through' : ''">
|
<div :class="listItem.checked ? 'strike-through' : ''">
|
||||||
{{ listItem.display }}
|
<RecipeIngredientListItem :ingredient="listItem" :disable-amount="!(listItem.quantity && (listItem.isFood || listItem.quantity !== 1))" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
@ -70,6 +70,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||||
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
||||||
import { ShoppingListItemOut } from "~/lib/api/types/group";
|
import { ShoppingListItemOut } from "~/lib/api/types/group";
|
||||||
@ -82,7 +83,7 @@ interface actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { ShoppingListItemEditor, MultiPurposeLabel },
|
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeIngredientListItem },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: Object as () => ShoppingListItemOut,
|
type: Object as () => ShoppingListItemOut,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export { useFraction } from "./use-fraction";
|
export { useFraction } from "./use-fraction";
|
||||||
export { useRecipe } from "./use-recipe";
|
export { useRecipe } from "./use-recipe";
|
||||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||||
export { parseIngredientText } from "./use-recipe-ingredients";
|
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
||||||
export { useTools } from "./use-recipe-tools";
|
export { useTools } from "./use-recipe-tools";
|
||||||
export { useRecipeMeta } from "./use-recipe-meta";
|
export { useRecipeMeta } from "./use-recipe-meta";
|
||||||
|
42
frontend/composables/recipes/use-recipe-ingredients.test.ts
Normal file
42
frontend/composables/recipes/use-recipe-ingredients.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { parseIngredientText } from "./use-recipe-ingredients";
|
||||||
|
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
|
describe(parseIngredientText.name, () => {
|
||||||
|
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||||
|
quantity: 1,
|
||||||
|
food: {
|
||||||
|
id: "1",
|
||||||
|
name: "Item 1",
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
id: "1",
|
||||||
|
name: "cup",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses ingredient note if disableAmount: true", () => {
|
||||||
|
const ingredient = createRecipeIngredient({ note: "foo" });
|
||||||
|
|
||||||
|
expect(parseIngredientText(ingredient, true)).toEqual("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds note section if note present", () => {
|
||||||
|
const ingredient = createRecipeIngredient({ note: "custom note" });
|
||||||
|
|
||||||
|
expect(parseIngredientText(ingredient, false)).toContain("custom note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ingredient text with fraction", () => {
|
||||||
|
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
|
||||||
|
|
||||||
|
expect(parseIngredientText(ingredient, false)).contain("1 <sup>1</sup>").and.to.contain("<sub>2</sub>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizes html", () => {
|
||||||
|
const ingredient = createRecipeIngredient({ note: "<script>alert('foo')</script>" });
|
||||||
|
|
||||||
|
expect(parseIngredientText(ingredient, false)).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
});
|
@ -10,11 +10,14 @@ function sanitizeIngredientHTML(rawHtml: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string {
|
export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1) {
|
||||||
// TODO: the backend now supplies a "display" property which does this for us, so we don't need this function
|
|
||||||
|
|
||||||
if (disableAmount) {
|
if (disableAmount) {
|
||||||
return ingredient.note || "";
|
return {
|
||||||
|
name: ingredient.note ? sanitizeIngredientHTML(ingredient.note) : undefined,
|
||||||
|
quantity: undefined,
|
||||||
|
unit: undefined,
|
||||||
|
note: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { quantity, food, unit, note } = ingredient;
|
const { quantity, food, unit, note } = ingredient;
|
||||||
@ -43,6 +46,17 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `${returnQty} ${unitDisplay || " "} ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " ");
|
return {
|
||||||
|
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||||
|
unit: unitDisplay ? sanitizeIngredientHTML(unitDisplay) : undefined,
|
||||||
|
name: food?.name ? sanitizeIngredientHTML(food.name) : undefined,
|
||||||
|
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string {
|
||||||
|
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, disableAmount, scale);
|
||||||
|
|
||||||
|
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||||
return sanitizeIngredientHTML(text);
|
return sanitizeIngredientHTML(text);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user