mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-10 06:55:35 -04:00
chore: Nuxt 4 upgrade (#7426)
This commit is contained in:
461
frontend/app/components/Domain/Recipe/RecipePrintView.vue
Normal file
461
frontend/app/components/Domain/Recipe/RecipePrintView.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="dense ? 'wrapper' : 'wrapper pa-3'">
|
||||
<section>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
>
|
||||
<img
|
||||
:key="imageKey"
|
||||
:src="recipeImageUrl"
|
||||
style="min-height: 50; max-width: 100%;"
|
||||
>
|
||||
</v-col>
|
||||
<v-col order="0">
|
||||
<v-card-title class="headline pl-0">
|
||||
<v-icon
|
||||
start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<div
|
||||
v-if="recipeYield"
|
||||
class="d-flex justify-space-between align-center pb-6"
|
||||
>
|
||||
<div>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="recipeYield" />
|
||||
</div>
|
||||
</div>
|
||||
<v-row class="d-flex justify-start">
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
/>
|
||||
</v-row>
|
||||
|
||||
<v-card-text
|
||||
v-if="preferences.showDescription"
|
||||
class="px-0"
|
||||
>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</section>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</v-card-title>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
>
|
||||
<h4
|
||||
v-if="ingredientSection.ingredients[0].title"
|
||||
class="ingredient-title mt-2"
|
||||
>
|
||||
{{ ingredientSection.ingredients[0].title }}
|
||||
</h4>
|
||||
<div
|
||||
class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
>
|
||||
<template
|
||||
v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||
:key="`ingredient-${ingredientIndex}`"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p
|
||||
class="ingredient-body"
|
||||
v-html="parseText(ingredient)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Instructions -->
|
||||
<section>
|
||||
<div
|
||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
>
|
||||
<v-card-title
|
||||
v-if="!sectionIndex"
|
||||
class="headline pl-0"
|
||||
>
|
||||
{{ $t("recipe.instructions") }}
|
||||
</v-card-title>
|
||||
<div
|
||||
v-for="(step, stepIndex) in instructionSection.instructions"
|
||||
:key="`instruction-${stepIndex}`"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4
|
||||
v-if="step.title"
|
||||
:key="`instruction-title-${stepIndex}`"
|
||||
class="instruction-title mb-2"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<h5>
|
||||
{{ step.summary ? step.summary : $t("recipe.step-index", {
|
||||
step: stepIndex
|
||||
+ instructionSection.stepOffset
|
||||
+ 1,
|
||||
}) }}
|
||||
</h5>
|
||||
<SafeMarkdown
|
||||
:source="step.text"
|
||||
class="recipe-step-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notes -->
|
||||
<div v-if="preferences.showNotes">
|
||||
<v-divider
|
||||
v-if="hasNotes"
|
||||
class="grey my-4"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<div
|
||||
v-for="(note, index) in recipe.notes"
|
||||
:key="index + 'note'"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<SafeMarkdown
|
||||
:source="note.text"
|
||||
class="note-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Nutrition -->
|
||||
<div v-if="preferences.showNutrition">
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</v-card-title>
|
||||
|
||||
<section>
|
||||
<div class="print-section">
|
||||
<table class="nutrition-table">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(value, key) in recipe.nutrition"
|
||||
:key="key"
|
||||
>
|
||||
<template v-if="value">
|
||||
<td>{{ labels[key].label }}</td>
|
||||
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
type IngredientSection = {
|
||||
sectionName: string;
|
||||
ingredients: RecipeIngredient[];
|
||||
};
|
||||
|
||||
type InstructionSection = {
|
||||
sectionName: string;
|
||||
stepOffset: number;
|
||||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
scale?: number;
|
||||
dense?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
dense: false,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { labels } = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay || props.recipe.recipeYield
|
||||
? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
// Group ingredients by section so we can style them independently
|
||||
const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
|
||||
// If title is set, ensure the section exists before adding ingredients
|
||||
let section: IngredientSection | undefined;
|
||||
if (title) {
|
||||
section = sections.find(sec => sec.sectionName === title);
|
||||
if (!section) {
|
||||
section = { sectionName: title, ingredients: [] };
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
ingredients.forEach((ingredient) => {
|
||||
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
|
||||
// Recursively add to the section for this referenced recipe
|
||||
addIngredientsToSections(
|
||||
ingredient.referencedRecipe.recipeIngredient,
|
||||
sections,
|
||||
"",
|
||||
);
|
||||
}
|
||||
else {
|
||||
const sectionName = title || ingredient.title || "";
|
||||
if (sectionName) {
|
||||
let sec = sections.find(sec => sec.sectionName === sectionName);
|
||||
if (!sec) {
|
||||
sec = { sectionName, ingredients: [] };
|
||||
sections.push(sec);
|
||||
}
|
||||
ingredient.title = sectionName;
|
||||
sec.ingredients.push(ingredient);
|
||||
}
|
||||
else {
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
}
|
||||
else {
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sections: IngredientSection[] = [];
|
||||
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||
return sections;
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
const instructionSections = computed<InstructionSection[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
sections[sections.length - 1].instructions.push(step);
|
||||
return sections;
|
||||
}, [] as InstructionSection[]);
|
||||
});
|
||||
|
||||
const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.scale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Makes all text solid black */
|
||||
.wrapper {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.wrapper,
|
||||
.wrapper :deep(*) {
|
||||
opacity: 1 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Prevents sections from being broken up between pages */
|
||||
.print-section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.v-card__text {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ingredient-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ingredient-title,
|
||||
.instruction-title {
|
||||
grid-column: 1 / span 2;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.ingredient-body,
|
||||
.recipe-step-body,
|
||||
.note-body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.nutrition-table {
|
||||
max-width: 80%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nutrition-table th,
|
||||
.nutrition-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nutrition-table th {
|
||||
font-weight: bold;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nutrition-table td:first-child {
|
||||
width: 70%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nutrition-table td:last-child {
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nutrition-table td {
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user