1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 07:39:41 +02:00

feat: Print Preferences (#2131)

* added basic recipe print settings
added print settings dialog
refactored print view to live inside print container

* removed unwanted padding

* changed preferences layout
This commit is contained in:
Michael Genson 2023-02-19 18:37:18 -06:00 committed by GitHub
parent b25cc70963
commit 670907b563
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 277 additions and 52 deletions

View file

@ -50,6 +50,7 @@
fab fab
color="info" color="info"
:card-menu="false" :card-menu="false"
:recipe="recipe"
:recipe-id="recipe.id" :recipe-id="recipe.id"
:recipe-scale="recipeScale" :recipe-scale="recipeScale"
:use-items="{ :use-items="{
@ -60,6 +61,7 @@
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: true, print: true,
printPreferences: true,
share: true, share: true,
publicUrl: recipe.settings ? recipe.settings.public : false, publicUrl: recipe.settings ? recipe.settings.public : false,
}" }"

View file

@ -51,6 +51,7 @@
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: false, print: false,
printPreferences: false,
share: true, share: true,
publicUrl: false, publicUrl: false,
}" }"

View file

@ -50,6 +50,7 @@
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: false, print: false,
printPreferences: false,
share: true, share: true,
publicUrl: false, publicUrl: false,
}" }"

View file

@ -2,6 +2,7 @@
<div class="text-center"> <div class="text-center">
<!-- Recipe Share Dialog --> <!-- Recipe Share Dialog -->
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" /> <RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipe" />
<BaseDialog <BaseDialog
v-model="recipeDeleteDialog" v-model="recipeDeleteDialog"
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
@ -115,10 +116,12 @@
<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 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 { planTypeOptions } from "~/composables/use-group-mealplan"; import { planTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe";
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";
@ -131,6 +134,7 @@ export interface ContextMenuIncludes {
mealplanner: boolean; mealplanner: boolean;
shoppingList: boolean; shoppingList: boolean;
print: boolean; print: boolean;
printPreferences: boolean;
share: boolean; share: boolean;
publicUrl: boolean; publicUrl: boolean;
} }
@ -144,6 +148,7 @@ export interface ContextMenuItem {
export default defineComponent({ export default defineComponent({
components: { components: {
RecipeDialogPrintPreferences,
RecipeDialogShare, RecipeDialogShare,
}, },
props: { props: {
@ -157,6 +162,7 @@ export default defineComponent({
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: true, print: true,
printPreferences: true,
share: true, share: true,
publicUrl: false, publicUrl: false,
}), }),
@ -195,6 +201,10 @@ export default defineComponent({
required: true, required: true,
type: String, type: String,
}, },
recipe: {
type: Object as () => Recipe,
default: undefined,
},
recipeId: { recipeId: {
required: true, required: true,
type: String, type: String,
@ -217,6 +227,7 @@ export default defineComponent({
const api = useUserApi(); const api = useUserApi();
const state = reactive({ const state = reactive({
printPreferencesDialog: false,
shareDialog: false, shareDialog: false,
recipeDeleteDialog: false, recipeDeleteDialog: false,
mealplannerDialog: false, mealplannerDialog: false,
@ -278,6 +289,12 @@ export default defineComponent({
color: undefined, color: undefined,
event: "print", event: "print",
}, },
printPreferences: {
title: i18n.tc("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
},
share: { share: {
title: i18n.tc("general.share"), title: i18n.tc("general.share"),
icon: $globals.icons.shareVariant, icon: $globals.icons.shareVariant,
@ -382,6 +399,9 @@ export default defineComponent({
mealplanner: () => { mealplanner: () => {
state.mealplannerDialog = true; state.mealplannerDialog = true;
}, },
printPreferences: () => {
state.printPreferencesDialog = true;
},
shoppingList: () => { shoppingList: () => {
getShoppingLists(); getShoppingLists();
state.shoppingListDialog = true; state.shoppingListDialog = true;

View file

@ -0,0 +1,89 @@
<template>
<BaseDialog
v-model="dialog"
:icon="$globals.icons.printerSettings"
:title="$tc('general.print-preferences')"
width="70%"
>
<div class="pa-6">
<v-container class="print-config mb-3 pa-0">
<v-row>
<v-col cols="auto" align-self="center" class="text-center">
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div>
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;">
<v-btn :value="ImagePosition.left">
<v-icon>{{ $globals.icons.dockLeft }}</v-icon>
</v-btn>
<v-btn :value="ImagePosition.right">
<v-icon>{{ $globals.icons.dockRight }}</v-icon>
</v-btn>
<v-btn :value="ImagePosition.hidden">
<v-icon>{{ $globals.icons.windowClose }}</v-icon>
</v-btn>
</v-btn-toggle>
</v-col>
<v-col cols="auto" align-self="start">
<v-row no-gutters>
<v-switch v-model="preferences.showDescription" hide-details :label="$tc('recipe.description')" />
</v-row>
<v-row no-gutters>
<v-switch v-model="preferences.showNotes" hide-details :label="$tc('recipe.notes')" />
</v-row>
</v-col>
</v-row>
</v-container>
<v-card
height="fit-content"
max-height="40vh"
width="100%"
class="print-preview"
style="overflow-y: auto;"
>
<RecipePrintView :recipe="recipe"/>
</v-card>
</div>
</BaseDialog>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
export default defineComponent({
components: {
RecipePrintView,
},
props: {
value: {
type: Boolean,
default: false,
},
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props, context) {
const preferences = useUserPrintPreferences();
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
return {
dialog,
ImagePosition,
preferences,
}
}
});
</script>

View file

@ -70,7 +70,7 @@
:recipe="recipe" :recipe="recipe"
class="px-1 my-4 d-print-none" class="px-1 my-4 d-print-none"
/> />
<RecipePrintView :recipe="recipe" :scale="scale" /> <RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container> </v-container>
</template> </template>
@ -96,7 +96,7 @@ import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue"; import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue"; import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue"; import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe } from "~/lib/api/types/recipe";
@ -116,7 +116,7 @@ const EDITOR_OPTIONS = {
export default defineComponent({ export default defineComponent({
components: { components: {
RecipePageHeader, RecipePageHeader,
RecipePrintView, RecipePrintContainer,
RecipePageComments, RecipePageComments,
RecipePageTitleContent, RecipePageTitleContent,
RecipePageEditorToolbar, RecipePageEditorToolbar,

View file

@ -0,0 +1,56 @@
<template>
<div class="print-container">
<RecipePrintView :recipe="recipe" :scale="scale" dense />
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({
components: {
RecipePrintView,
},
props: {
recipe: {
type: Object as () => Recipe,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
});
</script>
<style>
@media print {
body,
html {
margin-top: 0 !important;
}
.print-container {
display: block !important;
}
.v-main {
display: block;
}
.v-main__wrap {
position: absolute;
top: 0;
left: 0;
}
}
</style>
<style scoped>
.print-container {
display: none;
}
</style>

View file

@ -1,19 +1,32 @@
<template> <template>
<div class="print-container"> <div :class="dense ? 'wrapper' : 'wrapper pa-3'">
<section> <section>
<v-card-title class="headline pl-0"> <v-container class="ma-0 pa-0">
<v-icon left color="primary"> <v-row>
{{ $globals.icons.primary }} <v-col
</v-icon> v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
{{ recipe.name }} :order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
</v-card-title> cols="4"
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" /> 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 left color="primary">
{{ $globals.icons.primary }}
</v-icon>
{{ recipe.name }}
</v-card-title>
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" color="white" />
<v-card-text v-if="preferences.showDescription" class="px-0">
<SafeMarkdown :source="recipe.description" />
</v-card-text>
</v-col>
</v-row>
</v-container>
</section> </section>
<v-card-text class="px-0">
<SafeMarkdown :source="recipe.description" />
</v-card-text>
<!-- Ingredients --> <!-- Ingredients -->
<section> <section>
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title> <v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
@ -30,6 +43,7 @@
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }" :style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
> >
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"> <template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients">
<!-- eslint-disable-next-line vue/no-v-html -->
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" /> <p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" />
</template> </template>
</div> </div>
@ -57,24 +71,30 @@
</section> </section>
<!-- Notes --> <!-- Notes -->
<v-divider v-if="hasNotes" class="grey my-4"></v-divider> <div v-if="preferences.showNotes">
<v-divider v-if="hasNotes" class="grey my-4"></v-divider>
<section> <section>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'"> <div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<div class="print-section"> <div class="print-section">
<h4>{{ note.title }}</h4> <h4>{{ note.title }}</h4>
<SafeMarkdown :source="note.text" class="note-body" /> <SafeMarkdown :source="note.text" class="note-body" />
</div>
</div> </div>
</div> </section>
</section> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api"; import { defineComponent, computed } from "@nuxtjs/composition-api";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
import { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe"; import { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import { usePageState } from "~/composables/recipe-page/shared-state";
type IngredientSection = { type IngredientSection = {
sectionName: string; sectionName: string;
@ -93,15 +113,27 @@ export default defineComponent({
}, },
props: { props: {
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => NoUndefinedField<Recipe>,
required: true, required: true,
}, },
scale: { scale: {
type: Number, type: Number,
default: 1, default: 1,
}, },
dense: {
type: Boolean,
default: false
}
}, },
setup(props) { setup(props) {
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
// Group ingredients by section so we can style them independently // Group ingredients by section so we can style them independently
const ingredientSections = computed<IngredientSection[]>(() => { const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) { if (!props.recipe.recipeIngredient) {
@ -190,8 +222,12 @@ export default defineComponent({
return { return {
hasNotes, hasNotes,
imageKey,
ImagePosition,
parseText, parseText,
parseIngredientText, parseIngredientText,
preferences,
recipeImageUrl,
ingredientSections, ingredientSections,
instructionSections, instructionSections,
}; };
@ -199,38 +235,14 @@ export default defineComponent({
}); });
</script> </script>
<style>
@media print {
body,
html {
margin-top: 0 !important;
}
.print-container {
display: block !important;
}
.v-main {
display: block;
}
.v-main__wrap {
position: absolute;
top: 0;
left: 0;
}
}
</style>
<style scoped> <style scoped>
/* Makes all text solid black */ /* Makes all text solid black */
.print-container { .wrapper {
display: none;
background-color: white; background-color: white;
} }
.print-container, .wrapper,
.print-container >>> * { .wrapper >>> * {
opacity: 1 !important; opacity: 1 !important;
color: black !important; color: black !important;
} }

View file

@ -5,7 +5,7 @@
:key="index" :key="index"
:small="$vuetify.breakpoint.smAndDown" :small="$vuetify.breakpoint.smAndDown"
label label
color="accent custom-transparent" :color="color"
class="ma-1" class="ma-1"
> >
<v-icon left> <v-icon left>
@ -34,6 +34,10 @@ export default defineComponent({
type: String, type: String,
default: null, default: null,
}, },
color: {
type: String,
default: "accent custom-transparent"
},
}, },
setup(props) { setup(props) {
const { i18n } = useContext(); const { i18n } = useContext();

View file

@ -1,6 +1,18 @@
import { Ref, useContext } from "@nuxtjs/composition-api"; import { Ref, useContext } from "@nuxtjs/composition-api";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
export interface UserPrintPreferences {
imagePosition: string;
showDescription: boolean;
showNotes: boolean;
}
export enum ImagePosition {
hidden = "hidden",
left = "left",
right = "right",
}
export interface UserRecipePreferences { export interface UserRecipePreferences {
orderBy: string; orderBy: string;
orderDirection: string; orderDirection: string;
@ -9,6 +21,22 @@ export interface UserRecipePreferences {
useMobileCards: boolean; useMobileCards: boolean;
} }
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
const fromStorage = useLocalStorage(
"recipe-print-preferences",
{
imagePosition: "left",
showDescription: true,
showNotes: true,
},
{ mergeDefaults: true }
// we cast to a Ref because by default it will return an optional type ref
// but since we pass defaults we know all properties are set.
) as unknown as Ref<UserPrintPreferences>;
return fromStorage;
}
export function useUserSortPreferences(): Ref<UserRecipePreferences> { export function useUserSortPreferences(): Ref<UserRecipePreferences> {
const { $globals } = useContext(); const { $globals } = useContext();

View file

@ -111,6 +111,7 @@
"ok": "OK", "ok": "OK",
"options": "Options:", "options": "Options:",
"print": "Print", "print": "Print",
"print-preferences": "Print Preferences",
"random": "Random", "random": "Random",
"rating": "Rating", "rating": "Rating",
"recent": "Recent", "recent": "Recent",

View file

@ -31,6 +31,7 @@ import {
mdiAlertCircle, mdiAlertCircle,
mdiDotsVertical, mdiDotsVertical,
mdiPrinter, mdiPrinter,
mdiPrinterPosCog,
mdiShareVariant, mdiShareVariant,
mdiChevronDown, mdiChevronDown,
mdiHeart, mdiHeart,
@ -128,6 +129,10 @@ import {
mdiMessageText, mdiMessageText,
mdiChefHat, mdiChefHat,
mdiContentDuplicate, mdiContentDuplicate,
mdiDockLeft,
mdiDockRight,
mdiDockTop,
mdiDockBottom,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -176,6 +181,10 @@ export const icons = {
desktopTowerMonitor: mdiDesktopTowerMonitor, desktopTowerMonitor: mdiDesktopTowerMonitor,
devTo: mdiDevTo, devTo: mdiDevTo,
diceMultiple: mdiDiceMultiple, diceMultiple: mdiDiceMultiple,
dockTop: mdiDockTop,
dockBottom: mdiDockBottom,
dockLeft: mdiDockLeft,
dockRight: mdiDockRight,
dotsHorizontal: mdiDotsHorizontal, dotsHorizontal: mdiDotsHorizontal,
dotsVertical: mdiDotsVertical, dotsVertical: mdiDotsVertical,
download: mdiDownload, download: mdiDownload,
@ -211,6 +220,7 @@ export const icons = {
orderAlphabeticalAscending: mdiOrderAlphabeticalAscending, orderAlphabeticalAscending: mdiOrderAlphabeticalAscending,
pageLayoutBody: mdiPageLayoutBody, pageLayoutBody: mdiPageLayoutBody,
printer: mdiPrinter, printer: mdiPrinter,
printerSettings: mdiPrinterPosCog,
refreshCircle: mdiRefreshCircle, refreshCircle: mdiRefreshCircle,
robot: mdiRobot, robot: mdiRobot,
search: mdiMagnify, search: mdiMagnify,

View file

@ -246,6 +246,7 @@
duplicate: false, duplicate: false,
mealplanner: false, mealplanner: false,
print: true, print: true,
printPreferences: false,
share: false, share: false,
shoppingList: true, shoppingList: true,
publicUrl: false, publicUrl: false,