@@ -70,11 +96,13 @@
diff --git a/frontend/components/global/ImageCropper.vue b/frontend/components/global/ImageCropper.vue
new file mode 100644
index 00000000000..b36e471b836
--- /dev/null
+++ b/frontend/components/global/ImageCropper.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+ {{ control.icon }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts
new file mode 100644
index 00000000000..3bc8e7996e8
--- /dev/null
+++ b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, test } from "vitest";
+import { useExtractRecipeYield } from "./use-extract-recipe-yield";
+
+describe("test use extract recipe yield", () => {
+ test("when text empty return empty", () => {
+ const result = useExtractRecipeYield(null, 1);
+ expect(result).toStrictEqual("");
+ });
+
+ test("when text matches nothing return text", () => {
+ const val = "this won't match anything";
+ const result = useExtractRecipeYield(val, 1);
+ expect(result).toStrictEqual(val);
+
+ const resultScaled = useExtractRecipeYield(val, 5);
+ expect(resultScaled).toStrictEqual(val);
+ });
+
+ test("when text matches a mixed fraction, return a scaled fraction", () => {
+ const val = "10 1/2 units";
+ const result = useExtractRecipeYield(val, 1);
+ expect(result).toStrictEqual(val);
+
+ const resultScaled = useExtractRecipeYield(val, 3);
+ expect(resultScaled).toStrictEqual("31 1/2 units");
+
+ const resultScaledPartial = useExtractRecipeYield(val, 2.5);
+ expect(resultScaledPartial).toStrictEqual("26 1/4 units");
+
+ const resultScaledInt = useExtractRecipeYield(val, 4);
+ expect(resultScaledInt).toStrictEqual("42 units");
+ });
+
+ test("when text matches a fraction, return a scaled fraction", () => {
+ const val = "1/3 plates";
+ const result = useExtractRecipeYield(val, 1);
+ expect(result).toStrictEqual(val);
+
+ const resultScaled = useExtractRecipeYield(val, 2);
+ expect(resultScaled).toStrictEqual("2/3 plates");
+
+ const resultScaledInt = useExtractRecipeYield(val, 3);
+ expect(resultScaledInt).toStrictEqual("1 plates");
+
+ const resultScaledPartial = useExtractRecipeYield(val, 2.5);
+ expect(resultScaledPartial).toStrictEqual("5/6 plates");
+
+ const resultScaledMixed = useExtractRecipeYield(val, 4);
+ expect(resultScaledMixed).toStrictEqual("1 1/3 plates");
+ });
+
+ test("when text matches a decimal, return a scaled, rounded decimal", () => {
+ const val = "1.25 parts";
+ const result = useExtractRecipeYield(val, 1);
+ expect(result).toStrictEqual(val);
+
+ const resultScaled = useExtractRecipeYield(val, 2);
+ expect(resultScaled).toStrictEqual("2.5 parts");
+
+ const resultScaledInt = useExtractRecipeYield(val, 4);
+ expect(resultScaledInt).toStrictEqual("5 parts");
+
+ const resultScaledPartial = useExtractRecipeYield(val, 2.5);
+ expect(resultScaledPartial).toStrictEqual("3.125 parts");
+
+ const roundedVal = "1.33333333333333333333 parts";
+ const resultScaledRounded = useExtractRecipeYield(roundedVal, 2);
+ expect(resultScaledRounded).toStrictEqual("2.667 parts");
+ });
+
+ test("when text matches an int, return a scaled int", () => {
+ const val = "5 bowls";
+ const result = useExtractRecipeYield(val, 1);
+ expect(result).toStrictEqual(val);
+
+ const resultScaled = useExtractRecipeYield(val, 2);
+ expect(resultScaled).toStrictEqual("10 bowls");
+
+ const resultScaledPartial = useExtractRecipeYield(val, 2.5);
+ expect(resultScaledPartial).toStrictEqual("12.5 bowls");
+
+ const resultScaledLarge = useExtractRecipeYield(val, 10);
+ expect(resultScaledLarge).toStrictEqual("50 bowls");
+ });
+
+ test("when text contains an invalid fraction, return the original string", () => {
+ const valDivZero = "3/0 servings";
+ const resultDivZero = useExtractRecipeYield(valDivZero, 3);
+ expect(resultDivZero).toStrictEqual(valDivZero);
+
+ const valDivZeroMixed = "2 4/0 servings";
+ const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6);
+ expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed);
+ });
+
+ test("when text contains a weird or small fraction, return the original string", () => {
+ const valWeird = "2323231239087/134527431962272135 servings";
+ const resultWeird = useExtractRecipeYield(valWeird, 5);
+ expect(resultWeird).toStrictEqual(valWeird);
+
+ const valSmall = "1/20230225 lovable servings";
+ const resultSmall = useExtractRecipeYield(valSmall, 12);
+ expect(resultSmall).toStrictEqual(valSmall);
+ });
+
+ test("when text contains multiple numbers, the first is parsed as the servings amount", () => {
+ const val = "100 sets of 55 bowls";
+ const result = useExtractRecipeYield(val, 3);
+ expect(result).toStrictEqual("300 sets of 55 bowls");
+ })
+});
diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts
new file mode 100644
index 00000000000..53d17b264b9
--- /dev/null
+++ b/frontend/composables/recipe-page/use-extract-recipe-yield.ts
@@ -0,0 +1,132 @@
+import { useFraction } from "~/composables/recipes";
+
+const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/;
+const matchFraction = /(?:\d*\d*|0)\/\d*\d*/;
+const matchDecimal = /(\d+.\d+)|(.\d+)/;
+const matchInt = /\d+/;
+
+
+
+function extractServingsFromMixedFraction(fractionString: string): number | undefined {
+ const mixedSplit = fractionString.split(/\s/);
+ const wholeNumber = parseInt(mixedSplit[0]);
+ const fraction = mixedSplit[1];
+
+ const fractionSplit = fraction.split("/");
+ const numerator = parseInt(fractionSplit[0]);
+ const denominator = parseInt(fractionSplit[1]);
+
+ if (denominator === 0) {
+ return undefined; // if the denominator is zero, just give up
+ }
+ else {
+ return wholeNumber + (numerator / denominator);
+ }
+}
+
+function extractServingsFromFraction(fractionString: string): number | undefined {
+ const fractionSplit = fractionString.split("/");
+ const numerator = parseInt(fractionSplit[0]);
+ const denominator = parseInt(fractionSplit[1]);
+
+ if (denominator === 0) {
+ return undefined; // if the denominator is zero, just give up
+ }
+ else {
+ return numerator / denominator;
+ }
+}
+
+
+
+function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null {
+ if (!yieldString) {
+ return null;
+ }
+
+ const mixedFractionMatch = yieldString.match(matchMixedFraction);
+ if (mixedFractionMatch?.length) {
+ const match = mixedFractionMatch[0];
+ const servings = extractServingsFromMixedFraction(match);
+
+ // if the denominator is zero, return no match
+ if (servings === undefined) {
+ return null;
+ } else {
+ return [match, servings, true];
+ }
+ }
+
+ const fractionMatch = yieldString.match(matchFraction);
+ if (fractionMatch?.length) {
+ const match = fractionMatch[0]
+ const servings = extractServingsFromFraction(match);
+
+ // if the denominator is zero, return no match
+ if (servings === undefined) {
+ return null;
+ } else {
+ return [match, servings, true];
+ }
+ }
+
+ const decimalMatch = yieldString.match(matchDecimal);
+ if (decimalMatch?.length) {
+ const match = decimalMatch[0];
+ return [match, parseFloat(match), false];
+ }
+
+ const intMatch = yieldString.match(matchInt);
+ if (intMatch?.length) {
+ const match = intMatch[0];
+ return [match, parseInt(match), false];
+ }
+
+ return null;
+}
+
+function formatServings(servings: number, scale: number, isFraction: boolean): string {
+ const val = servings * scale;
+ if (Number.isInteger(val)) {
+ return val.toString();
+ } else if (!isFraction) {
+ return (Math.round(val * 1000) / 1000).toString();
+ }
+
+ // convert val into a fraction string
+ const { frac } = useFraction();
+
+ let valString = "";
+ const fraction = frac(val, 10, true);
+
+ if (fraction[0] !== undefined && fraction[0] > 0) {
+ valString += fraction[0];
+ }
+
+ if (fraction[1] > 0) {
+ valString += ` ${fraction[1]}/${fraction[2]}`;
+ }
+
+ return valString.trim();
+}
+
+
+export function useExtractRecipeYield(yieldString: string | null, scale: number): string {
+ if (!yieldString) {
+ return "";
+ }
+
+ const match = findMatch(yieldString);
+ if (!match) {
+ return yieldString;
+ }
+
+ const [matchString, servings, isFraction] = match;
+
+ const formattedServings = formatServings(servings, scale, isFraction);
+ if (!formattedServings) {
+ return yieldString // this only happens with very weird or small fractions
+ } else {
+ return yieldString.replace(matchString, formatServings(servings, scale, isFraction));
+ }
+}
diff --git a/frontend/composables/recipes/index.ts b/frontend/composables/recipes/index.ts
index 2d58768320d..4578756fa34 100644
--- a/frontend/composables/recipes/index.ts
+++ b/frontend/composables/recipes/index.ts
@@ -1,6 +1,6 @@
export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe";
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 { useRecipeMeta } from "./use-recipe-meta";
diff --git a/frontend/composables/recipes/use-recipe-ingredients.test.ts b/frontend/composables/recipes/use-recipe-ingredients.test.ts
new file mode 100644
index 00000000000..c7e6900bea7
--- /dev/null
+++ b/frontend/composables/recipes/use-recipe-ingredients.test.ts
@@ -0,0 +1,51 @@
+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 => ({
+ 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, 1, true)).contain("1 1").and.to.contain("2");
+ });
+
+ test("ingredient text with fraction no formatting", () => {
+ const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
+ const result = parseIngredientText(ingredient, false, 1, false);
+
+ expect(result).not.contain("<");
+ expect(result).not.contain(">");
+ expect(result).contain("1 1/2");
+ });
+
+ test("sanitizes html", () => {
+ const ingredient = createRecipeIngredient({ note: "" });
+
+ expect(parseIngredientText(ingredient, false)).not.toContain("