Feature/shareable recipes (#866)

* simplify context menu

* move computed to comp-api

* feat:  create share tokens for recipes for sharing recieps to non-users

* feat:  shareable recipe links with og tags
This commit is contained in:
Hayden 2021-12-05 11:55:46 -09:00 committed by GitHub
parent ba4107348f
commit b2673d75bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 914 additions and 199 deletions

View File

@ -32,7 +32,7 @@ export abstract class BaseCRUDAPI<T, U> extends BaseAPI implements CrudAPIInterf
abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string;
async getAll(start = 0, limit = 9999, params = {}) {
async getAll(start = 0, limit = 9999, params = {} as any) {
return await this.requests.get<T[]>(this.baseRoute, {
params: { start, limit, ...params },
});

View File

@ -0,0 +1,26 @@
import { BaseCRUDAPI } from "~/api/_base";
const prefix = "/api";
const routes = {
shareToken: `${prefix}/shared/recipes`,
shareTokenId: (id: string) => `${prefix}/shared/recipes/${id}`,
};
export interface RecipeShareTokenCreate {
recipeId: number;
expiresAt?: Date;
}
export interface RecipeShareToken {
recipeId: number;
id: string;
groupId: number;
expiresAt: string;
createdAt: string;
}
export class RecipeShareApi extends BaseCRUDAPI<RecipeShareToken, RecipeShareTokenCreate> {
baseRoute: string = routes.shareToken;
itemRoute = routes.shareTokenId;
}

View File

@ -1,5 +1,6 @@
import { CreateAsset, ParsedIngredient, Parser, RecipeZipToken, BulkCreatePayload } from "./types";
import { CommentsApi } from "./recipe-comments";
import { RecipeShareApi } from "./recipe-share";
import { BaseCRUDAPI } from "~/api/_base";
import { Recipe, CreateRecipe } from "~/types/api-types/recipe";
@ -26,18 +27,22 @@ const routes = {
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
recipeShareToken: (token: string) => `${prefix}/recipes/shared/${token}`,
};
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug;
public comments: CommentsApi;
comments: CommentsApi;
share: RecipeShareApi;
constructor(requests: ApiRequestInstance) {
super(requests);
this.comments = new CommentsApi(requests);
this.share = new RecipeShareApi(requests);
}
async getAllByCategory(categories: string[]) {
@ -116,4 +121,8 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
getZipRedirectUrl(recipeSlug: string, token: string) {
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
}
async getShared(item_id: string) {
return await this.requests.get<Recipe>(routes.recipeShareToken(item_id));
}
}

View File

@ -34,7 +34,7 @@
:name="name"
:recipe-id="recipeId"
:use-items="{
delete: true,
delete: false,
edit: true,
download: true,
mealplanner: true,

View File

@ -43,7 +43,7 @@
:name="name"
:recipe-id="recipeId"
:use-items="{
delete: true,
delete: false,
edit: true,
download: true,
mealplanner: true,

View File

@ -1,5 +1,7 @@
<template>
<div class="text-center">
<!-- Recipe Share Dialog -->
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
<BaseDialog
v-model="recipeDeleteDialog"
:title="$t('recipe.delete-recipe')"
@ -75,7 +77,7 @@
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { useClipboard, useShare } from "@vueuse/core";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { MealType, planTypeOptions } from "~/composables/use-group-mealplan";
@ -92,11 +94,14 @@ export interface ContextMenuIncludes {
export interface ContextMenuItem {
title: string;
icon: string;
color: string;
color: string | undefined;
event: string;
}
export default defineComponent({
components: {
RecipeDialogShare,
},
props: {
useItems: {
type: Object as () => ContextMenuIncludes,
@ -152,6 +157,7 @@ export default defineComponent({
const api = useUserApi();
const state = reactive({
shareDialog: false,
recipeDeleteDialog: false,
mealplannerDialog: false,
loading: false,
@ -171,7 +177,7 @@ export default defineComponent({
edit: {
title: i18n.t("general.edit") as string,
icon: $globals.icons.edit,
color: "primary",
color: undefined,
event: "edit",
},
delete: {
@ -183,25 +189,25 @@ export default defineComponent({
download: {
title: i18n.t("general.download") as string,
icon: $globals.icons.download,
color: "primary",
color: undefined,
event: "download",
},
mealplanner: {
title: "Add to Plan",
icon: $globals.icons.calendar,
color: "primary",
color: undefined,
event: "mealplanner",
},
print: {
title: i18n.t("general.print") as string,
icon: $globals.icons.printer,
color: "primary",
color: undefined,
event: "print",
},
share: {
title: i18n.t("general.share") as string,
icon: $globals.icons.shareVariant,
color: "primary",
color: undefined,
event: "share",
},
};
@ -221,14 +227,6 @@ export default defineComponent({
const icon = props.menuIcon || $globals.icons.dotsVertical;
function getRecipeUrl() {
return `${window.location.origin}/recipe/${props.slug}`;
}
function getRecipeText() {
return i18n.t("recipe.share-recipe-message", [props.name]);
}
// ===========================================================================
// Context Menu Event Handler
@ -247,23 +245,6 @@ export default defineComponent({
}
}
const { share, isSupported: shareIsSupported } = useShare();
const { copy } = useClipboard();
async function handleShareEvent() {
if (shareIsSupported) {
share({
title: props.name,
url: getRecipeUrl(),
text: getRecipeText() as string,
});
} else {
await copy(getRecipeUrl());
alert.success("Recipe link copied to clipboard");
}
}
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: state.newMealdate,
@ -292,7 +273,9 @@ export default defineComponent({
mealplanner: () => {
state.mealplannerDialog = true;
},
share: handleShareEvent,
share: () => {
state.shareDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {

View File

@ -1,88 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="700">
<template #activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on"> {{ $t("recipe.api-extras") }} </v-btn>
</template>
<v-card>
<v-card-title> {{ $t("recipe.api-extras") }} </v-card-title>
<v-card-text :key="formKey">
<v-row v-for="(value, key, index) in extras" :key="index" align="center">
<v-col cols="12" sm="1">
<v-btn fab text x-small color="white" elevation="0" @click="removeExtra(key)">
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-col>
<v-col cols="12" md="3" sm="6">
<v-text-field :label="$t('recipe.object-key')" :value="key" @input="updateKey(index)"> </v-text-field>
</v-col>
<v-col cols="12" md="8" sm="6">
<v-text-field v-model="extras[key]" :label="$t('recipe.object-value')"> </v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-form ref="addKey">
<v-text-field
v-model="newKeyName"
:label="$t('recipe.new-key-name')"
class="pr-4"
:rules="[rules.required, rules.whiteSpace]"
></v-text-field>
</v-form>
<v-btn color="info" text @click="append"> {{ $t("recipe.add-key") }} </v-btn>
<v-spacer></v-spacer>
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
extras: {
type: Object,
default: () => ({}),
},
},
data() {
return {
newKeyName: null,
dialog: false,
formKey: 1,
rules: {
required: (v) => !!v || this.$i18n.t("recipe.key-name-required"),
whiteSpace: (v) => !v || v.split(" ").length <= 1 || this.$i18n.t("recipe.no-white-space-allowed"),
},
};
},
methods: {
save() {
this.$emit("save", this.extras);
this.dialog = false;
},
append() {
if (this.$refs.addKey.validate()) {
this.extras[this.newKeyName] = "value";
this.formKey += 1;
}
},
removeExtra(key) {
delete this.extras[key];
this.formKey += 1;
},
},
};
</script>
<style></style>

View File

@ -0,0 +1,181 @@
<template>
<div>
<BaseDialog v-model="dialog" title="Share Recipe" :icon="$globals.icons.link">
<v-card-text>
<v-menu
v-model="datePickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="expirationDate"
label="Expiration Date"
hint="Default 30 Days"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="expirationDate" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>
</v-card-text>
<v-card-actions class="justify-end">
<BaseButton small @click="createNewToken"> {{ $t("general.new") }}</BaseButton>
</v-card-actions>
<v-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)">
<v-list-item-avatar color="grey">
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title> Expires At </v-list-item-title>
<v-list-item-subtitle>{{ $d(new Date(token.expiresAt), "long") }}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn icon @click.stop="deleteToken(token.id)">
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-action>
<v-btn icon @click.stop="copyTokenLink(token.id)">
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</BaseDialog>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, toRefs, reactive, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/shared";
import { useClipboard, useShare } from "@vueuse/core";
import { RecipeShareToken } from "~/api/class-interfaces/recipes/recipe-share";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
export default defineComponent({
props: {
value: {
type: Boolean,
default: false,
},
recipeId: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
},
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
console.log(val);
context.emit("input", val);
},
});
const state = reactive({
datePickerMenu: false,
expirationDate: "",
tokens: [] as RecipeShareToken[],
});
whenever(
() => props.value,
() => {
// Set expiration date to today + 30 Days
const today = new Date();
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
state.expirationDate = expirationDate.toISOString().substring(0, 10);
refreshTokens();
}
);
// ============================================================
// Token Actions
const userApi = useUserApi();
async function createNewToken() {
// Convert expiration date to timestamp
const expirationDate = new Date(state.expirationDate);
const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId,
expiresAt: expirationDate,
});
if (data) {
state.tokens.push(data);
}
}
async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id);
state.tokens = state.tokens.filter((token) => token.id !== id);
}
async function refreshTokens() {
const { data } = await userApi.recipes.share.getAll(0, 999, { recipe_id: props.recipeId });
if (data) {
state.tokens = data;
}
}
const { i18n } = useContext();
const { share, isSupported: shareIsSupported } = useShare();
const { copy } = useClipboard();
function getRecipeText() {
return i18n.t("recipe.share-recipe-message", [props.name]);
}
function getTokenLink(token: string) {
return `${window.location.origin}/shared/recipes/${token}`;
}
async function copyTokenLink(token: string) {
await copy(getTokenLink(token));
alert.success("Recipe link copied to clipboard");
}
async function shareRecipe(token: string) {
if (shareIsSupported) {
share({
title: props.name,
url: getTokenLink(token),
text: getRecipeText() as string,
});
} else {
await copyTokenLink(token);
}
}
return {
...toRefs(state),
dialog,
createNewToken,
deleteToken,
shareRecipe,
copyTokenLink,
};
},
});
</script>

View File

@ -60,7 +60,7 @@
<div class="d-flex justify-space-between justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
<BaseButton v-if="!public" minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
<template #icon>
{{ $globals.icons.primary }}
</template>
@ -186,6 +186,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
public: {
type: Boolean,
default: false,
},
},
setup(props, context) {

View File

@ -14,28 +14,27 @@
<v-spacer></v-spacer>
<div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch">
<v-text-field
readonly
class="mt-6 rounded-xl"
rounded
dark
solo
dense
flat
:prepend-inner-icon="$globals.icons.search"
background-color="primary lighten-1"
color="white"
placeholder="Press '/'"
>
</v-text-field>
</div>
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn>
<!-- Navigation Menu -->
<template v-if="menu">
<div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch">
<v-text-field
readonly
class="mt-6 rounded-xl"
rounded
dark
solo
dense
flat
:prepend-inner-icon="$globals.icons.search"
background-color="primary lighten-1"
color="white"
placeholder="Press '/'"
>
</v-text-field>
</div>
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn>
<v-btn v-if="$auth.loggedIn" text @click="$auth.logout()">
<v-icon left>{{ $globals.icons.logout }}</v-icon>
{{ $t("user.logout") }}

View File

@ -7,3 +7,4 @@ export { useTags, useCategories, allCategories, allTags } from "./use-tags-categ
export { parseIngredientText } from "./use-recipe-ingredients";
export { useRecipeSearch } from "./use-recipe-search";
export { useTools } from "./use-recipe-tools";
export { useRecipeMeta } from "./use-recipe-meta";

View File

@ -0,0 +1,50 @@
import { Ref } from "@nuxtjs/composition-api";
import { useStaticRoutes } from "../api";
import { Recipe } from "~/types/api-types/recipe";
export const useRecipeMeta = (recipe: Ref<Recipe>) => {
const { recipeImage } = useStaticRoutes();
console.log(recipe.value);
return () => {
return {
title: recipe?.value?.name || "Recipe",
// @ts-ignore
mainImage: recipeImage(recipe?.value?.image),
meta: [
{ hid: "og:title", property: "og:title", content: recipe?.value?.name || "Recipe" },
{
hid: "og:desc",
property: "og:description",
content: recipe?.value?.description || "",
},
{
hid: "og-image",
property: "og:image",
content: recipeImage(recipe?.value?.image || ""),
},
{
hid: "twitter:title",
property: "twitter:title",
content: recipe?.value?.name || "Recipe",
},
{
hid: "twitter:desc",
property: "twitter:description",
content: recipe?.value?.description || "",
},
{ hid: "t-type", name: "twitter:card", content: "summary_large_image" },
],
__dangerouslyDisableSanitizers: ["script"],
script: [
{
innerHTML: JSON.stringify({
"@context": "http://schema.org",
"@type": "Recipe",
...recipe.value,
}),
type: "application/ld+json",
},
],
};
};
};

View File

@ -1,28 +1,22 @@
<template>
<v-app dark>
<TheSnackbar />
<AppHeader :menu="false"> </AppHeader>
<v-main>
<v-scroll-x-transition>
<Nuxt />
</v-scroll-x-transition>
</v-main>
<AppFooter />
</v-app>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import AppFooter from "@/components/Layout/AppFooter.vue";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
import AppHeader from "@/components/Layout/AppHeader.vue";
export default defineComponent({
components: { AppFooter, TheSnackbar },
setup() {
return {};
},
components: { TheSnackbar, AppHeader },
});
</script>
<style scoped>
</style>+
</script>

View File

@ -433,6 +433,7 @@ import {
useMeta,
useRoute,
useRouter,
onMounted,
} from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
@ -442,7 +443,7 @@ import RecipeCategoryTagSelector from "@/components/Domain/Recipe/RecipeCategory
import RecipeDialogBulkAdd from "@/components/Domain/Recipe//RecipeDialogBulkAdd.vue";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { useRecipe } from "~/composables/recipes";
import { useRecipe, useRecipeMeta } from "~/composables/recipes";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
@ -461,6 +462,7 @@ import RecipeComments from "~/components/Domain/Recipe/RecipeComments.vue";
import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils";
import { Tool } from "~/api/class-interfaces/tools";
import { useRouteQuery } from "~/composables/use-router";
export default defineComponent({
components: {
@ -504,6 +506,22 @@ export default defineComponent({
const slug = route.value.params.slug;
const api = useUserApi();
// ===============================================================
// Edit on Navigate
const edit = useRouteQuery("edit", "");
onMounted(() => {
console.log("edit", edit.value);
if (edit.value) {
console.log("edit", edit.value);
state.form = edit.value === "true";
}
});
// ===============================================================
// Guest Mode
// ===============================================================
// Check Before Leaving
@ -707,31 +725,10 @@ export default defineComponent({
// ===============================================================
// Metadata
const structuredData = computed(() => {
// TODO: Get this working with other scrapers, unsure why it isn't properly being delivered to clients.
return {
"@context": "http://schema.org",
"@type": "Recipe",
...recipe.value,
};
});
// @ts-ignore
const metaData = useRecipeMeta(recipe);
useMeta(() => {
return {
title: recipe?.value?.name || "Recipe",
// @ts-ignore
mainImage: recipeImage(recipe?.value?.image),
meta: [
{
hid: "description",
name: "description",
content: recipe?.value?.description || "",
},
],
__dangerouslyDisableSanitizers: ["script"],
script: [{ innerHTML: JSON.stringify(structuredData), type: "application/ld+json" }],
};
});
useMeta(metaData);
return {
createApiExtra,
@ -763,22 +760,6 @@ export default defineComponent({
// @ts-ignore
return this.$vuetify.breakpoint.xs ? "200" : "400";
},
// Won't work with Composition API in Vue 2. In Vue 3, this will happen in the setup function.
edit: {
set(val) {
// @ts-ignore
this.$router.replace({ query: { ...this.$route.query, val } });
},
get() {
// @ts-ignore
return this.$route.query.edit;
},
},
},
mounted() {
if (this.edit) {
this.form = true;
}
},
methods: {
printRecipe() {

View File

@ -0,0 +1,399 @@
<template>
<v-container
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card>
<v-card v-else-if="recipe" class="d-print-none">
<!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center">
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<v-divider></v-divider>
<div class="d-flex justify-center mt-5">
<RecipeTimeCard
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</div>
</v-card-text>
</v-card>
<v-img
:key="imageKey"
:max-width="enableLandscape ? null : '50%'"
:height="hideImage ? '50' : imageHeight"
:src="recipeImage(recipe.slug, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
<RecipeTimeCard
v-if="enableLandscape"
:class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</v-img>
</div>
<v-divider></v-divider>
<!-- Editors -->
<LazyRecipeJsonEditor v-if="jsonEditor" v-model="recipe" class="mt-10" :options="jsonEditorOptions" />
<div v-else>
<v-card-text
:class="{
'px-2': $vuetify.breakpoint.smAndDown,
}"
>
<!-- Recipe Title Section -->
<template v-if="!form && enableLandscape">
<v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
</template>
<template v-else-if="form">
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
>
</v-text-field>
<div class="d-flex flex-wrap">
<v-text-field v-model="recipe.totalTime" class="mx-2" :label="$t('recipe.total-time')"></v-text-field>
<v-text-field v-model="recipe.prepTime" class="mx-2" :label="$t('recipe.prep-time')"></v-text-field>
<v-text-field v-model="recipe.performTime" class="mx-2" :label="$t('recipe.perform-time')"></v-text-field>
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea>
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
</template>
<div class="d-flex justify-space-between align-center pb-3">
<v-tooltip v-if="!form" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
v-bind="attrs"
@click="scale = 1"
v-on="on"
>
{{ scaledYield }}
</v-btn>
</template>
<span> Reset Scale </span>
</v-tooltip>
<template v-if="!recipe.settings.disableAmount && !form">
<v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null">
<v-icon>
{{ $globals.icons.minus }}
</v-icon>
</v-btn>
<v-btn color="secondary darken-1" small @click="scale++">
<v-icon>
{{ $globals.icons.createAlt }}
</v-icon>
</v-btn>
</template>
<v-spacer></v-spacer>
<RecipeRating
v-if="enableLandscape"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<RecipeIngredients
v-if="!form"
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
/>
<!-- Recipe Tools Display -->
<div v-if="!form && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">Required Tools</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipe.tools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="updateTool(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
</div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.tags" :is-category="false" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
/>
</div>
</v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<RecipeInstructions
v-model="recipe.recipeInstructions"
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="form"
public
/>
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
<div v-if="!$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.tags" :is-category="false" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
/>
</div>
<RecipeNotes v-model="recipe.notes" :edit="form" />
</v-col>
</v-row>
<v-card-actions class="justify-end">
<v-btn
v-if="recipe.orgURL"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="recipe.orgURL"
color="secondary darken-1"
target="_blank"
class="rounded-sm mr-4"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-card-actions>
</v-card-text>
</div>
</v-card>
<RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container>
</template>
<script lang="ts">
import {
computed,
defineComponent,
reactive,
toRefs,
useAsync,
useContext,
useMeta,
useRoute,
} from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import { useRecipeMeta } from "~/composables/recipes";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeAssets from "~/components/Domain/Recipe/RecipeAssets.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
export default defineComponent({
components: {
RecipeAssets,
RecipeChips,
RecipeIngredients,
RecipeInstructions,
RecipeNotes,
RecipeNutrition,
RecipePrintView,
RecipeRating,
RecipeTimeCard,
VueMarkdown,
},
layout: "basic",
setup() {
const route = useRoute();
const id = route.value.params.id;
const api = useUserApi();
const state = reactive({
form: false,
scale: 1,
hideImage: false,
imageKey: 1,
loadFailed: false,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
});
const { recipeImage } = useStaticRoutes();
const recipe = useAsync(async () => {
const { data } = await api.recipes.getShared(id);
if (data) {
return data;
}
});
// @ts-ignore
const recipeMeta = useRecipeMeta(recipe);
useMeta(recipeMeta);
// @ts-ignore
const { $vuetify } = useContext();
const enableLandscape = computed(() => {
const preferLandscape = recipe?.value?.settings?.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
const scaledYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt * state.scale));
}
return recipe.value?.recipeYield;
});
return {
...toRefs(state),
recipe,
recipeImage,
scaledYield,
enableLandscape,
};
},
head: {},
computed: {
imageHeight() {
// @ts-ignore
return this.$vuetify.breakpoint.xs ? "200" : "400";
},
},
methods: {
printRecipe() {
window.print();
},
},
});
</script>

View File

@ -13,6 +13,7 @@ from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.shared import RecipeShareTokenModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.server.task import ServerTaskModel
@ -29,6 +30,7 @@ from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
from mealie.schema.recipe.recipe_tool import RecipeTool
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
from mealie.schema.server import ServerTask
@ -99,6 +101,10 @@ class Database:
def tags(self) -> TagsDataAccessModel:
return TagsDataAccessModel(self.session, pk_slug, Tag, RecipeTagResponse)
@cached_property
def recipe_share_tokens(self) -> AccessModel[RecipeShareToken, RecipeShareTokenModel]:
return AccessModel(self.session, pk_id, RecipeShareTokenModel, RecipeShareToken)
# ================================================================
# Site Items

View File

@ -19,6 +19,7 @@ from .instruction import RecipeInstruction
from .note import Note
from .nutrition import Nutrition
from .settings import RecipeSettings
from .shared import RecipeShareTokenModel
from .tag import Tag, recipes2tags
from .tool import recipes_to_tools
@ -87,6 +88,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
collection_class=ordering_list("position"),
)
share_tokens = orm.relationship(RecipeShareTokenModel, back_populates="recipe")
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
# Mealie Specific

View File

@ -0,0 +1,27 @@
from datetime import datetime, timedelta
from uuid import uuid4
import sqlalchemy as sa
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import GUID, auto_init
def defaut_expires_at_time() -> datetime:
return datetime.utcnow() + timedelta(days=30)
class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens"
id = sa.Column(GUID, primary_key=True, default=uuid4)
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
recipe_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"), nullable=False)
recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at = sa.Column(sa.DateTime, nullable=False)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -9,6 +9,7 @@ from . import (
groups,
parser,
recipe,
shared,
shopping_lists,
tags,
tools,
@ -23,6 +24,7 @@ router.include_router(auth.router)
router.include_router(users.router)
router.include_router(groups.router)
router.include_router(recipe.router)
router.include_router(shared.router)
router.include_router(comments.router)
router.include_router(parser.router)
router.include_router(unit_and_foods.router)

View File

@ -1,6 +1,14 @@
from fastapi import APIRouter
from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, recipe_export
from . import (
all_recipe_routes,
bulk_actions,
comments,
image_and_assets,
recipe_crud_routes,
recipe_export,
shared_routes,
)
prefix = "/recipes"
@ -14,3 +22,4 @@ router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
router.include_router(bulk_actions.export_router, prefix=prefix, tags=["Recipe: Bulk Exports"])
router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])

View File

@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.schema.recipe import Recipe
router = APIRouter()
@router.get("/shared/{token_id}", response_model=Recipe)
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)):
db = get_database(session)
token_summary = db.recipe_share_tokens.get_one(token_id)
if token_summary is None:
return None
return token_summary.recipe

View File

@ -0,0 +1,23 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.shared.recipe_shared_service import RecipeShareTokenSummary, SharedRecipeService
router = UserAPIRouter(prefix="/shared")
shared_router = RouterFactory(SharedRecipeService, prefix="/recipes", tags=["Shared: Recipes"])
@shared_router.get("", response_model=list[RecipeShareTokenSummary])
def get_all_shared(
recipe_id: int = None,
shared_recipe_service: SharedRecipeService = Depends(SharedRecipeService.private),
):
"""
Get all shared recipes
"""
return shared_recipe_service.get_all(recipe_id)
router.include_router(shared_router)

View File

@ -0,0 +1,34 @@
from datetime import datetime, timedelta
from fastapi_camelcase import CamelModel
from pydantic import UUID4, Field
from .recipe import Recipe
def defaut_expires_at_time() -> datetime:
return datetime.utcnow() + timedelta(days=30)
class RecipeShareTokenCreate(CamelModel):
recipe_id: int
expires_at: datetime = Field(default_factory=defaut_expires_at_time)
class RecipeShareTokenSave(RecipeShareTokenCreate):
group_id: UUID4
class RecipeShareTokenSummary(RecipeShareTokenSave):
id: UUID4
created_at: datetime
class Config:
orm_mode = True
class RecipeShareToken(RecipeShareTokenSummary):
recipe: Recipe
class Config:
orm_mode = True

View File

View File

@ -0,0 +1,51 @@
from functools import cached_property
from pydantic import UUID4
from mealie.schema.recipe.recipe_share_token import (
RecipeShareToken,
RecipeShareTokenCreate,
RecipeShareTokenSave,
RecipeShareTokenSummary,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class SharedRecipeService(
CrudHttpMixins[RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenCreate],
UserHttpService[UUID4, RecipeShareToken],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeShareToken
@cached_property
def dal(self):
return self.db.recipe_share_tokens
def populate_item(self, id: UUID4) -> RecipeShareToken:
self.item = self.dal.get_one(id)
return self.item
def get_all(self, recipe_id=None) -> list[RecipeShareTokenSummary]:
# sourcery skip: assign-if-exp, inline-immediately-returned-variable
if recipe_id:
return self.db.recipe_share_tokens.multi_query(
{"group_id": self.group_id, "recipe_id": recipe_id},
override_schema=RecipeShareTokenSummary,
)
else:
return self.db.recipe_share_tokens.multi_query(
{"group_id": self.group_id}, override_schema=RecipeShareTokenSummary
)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id)
return self._create_one(save_data)
def delete_one(self, item_id: UUID4 = None) -> None:
item_id = item_id or self.item.id
return self.dal.delete(item_id)