feature/favorite-recipes (#443)

* add favorites options

* bump dependencies

* add badges to all cards

* typo

* remove console.log

* fix site-loader viewport

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-29 15:54:18 -08:00 committed by GitHub
parent 57f7ea3750
commit 6f38fcf81b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 365 additions and 82 deletions

View file

@ -74,6 +74,9 @@ class AppRoutes:
def groups_id(self, id): def groups_id(self, id):
return f"{self.prefix}/groups/{id}" return f"{self.prefix}/groups/{id}"
def meal_plans_id(self, id):
return f"{self.prefix}/meal-plans/{id}"
def meal_plans_id_shopping_list(self, id): def meal_plans_id_shopping_list(self, id):
return f"{self.prefix}/meal-plans/{id}/shopping-list" return f"{self.prefix}/meal-plans/{id}/shopping-list"
@ -122,6 +125,12 @@ class AppRoutes:
def users_id(self, id): def users_id(self, id):
return f"{self.prefix}/users/{id}" return f"{self.prefix}/users/{id}"
def users_id_favorites(self, id):
return f"{self.prefix}/users/{id}/favorites/"
def users_id_favorites_slug(self, id, slug):
return f"{self.prefix}/users/{id}/favorites/{slug}"
def users_id_image(self, id): def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image" return f"{self.prefix}/users/{id}/image"

View file

@ -17673,7 +17673,6 @@
"integrity": "sha512-8q67ORQ9O0Ms0nlqsXTVhaBefRBaLrzPxOewAZhdcO7onHwcO5/wRdWtHhZgfpCZlhY7NogkU16z3WnorSSkEA==", "integrity": "sha512-8q67ORQ9O0Ms0nlqsXTVhaBefRBaLrzPxOewAZhdcO7onHwcO5/wRdWtHhZgfpCZlhY7NogkU16z3WnorSSkEA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/core": "^7.11.0",
"@babel/helper-compilation-targets": "^7.9.6", "@babel/helper-compilation-targets": "^7.9.6",
"@babel/helper-module-imports": "^7.8.3", "@babel/helper-module-imports": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
@ -17686,7 +17685,6 @@
"@vue/babel-plugin-jsx": "^1.0.3", "@vue/babel-plugin-jsx": "^1.0.3",
"@vue/babel-preset-jsx": "^1.2.4", "@vue/babel-preset-jsx": "^1.2.4",
"babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-dynamic-import-node": "^2.3.3",
"core-js": "^3.6.5",
"core-js-compat": "^3.6.5", "core-js-compat": "^3.6.5",
"semver": "^6.1.0" "semver": "^6.1.0"
} }
@ -17849,7 +17847,8 @@
"version": "4.5.12", "version": "4.5.12",
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.12.tgz",
"integrity": "sha512-STgbvNv/3iHAKArc18b/qjN7RX1FTrfxPeHH26GOr/A8lJes7+CSluZZ8E5R7Zr/vL0zOqOkUVDAjFXVf4zWQA==", "integrity": "sha512-STgbvNv/3iHAKArc18b/qjN7RX1FTrfxPeHH26GOr/A8lJes7+CSluZZ8E5R7Zr/vL0zOqOkUVDAjFXVf4zWQA==",
"dev": true "dev": true,
"requires": {}
}, },
"@vue/cli-service": { "@vue/cli-service": {
"version": "4.5.12", "version": "4.5.12",
@ -18141,7 +18140,8 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz",
"integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==", "integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==",
"dev": true "dev": true,
"requires": {}
}, },
"@vue/web-component-wrapper": { "@vue/web-component-wrapper": {
"version": "1.3.0", "version": "1.3.0",
@ -18361,7 +18361,8 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz",
"integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==",
"dev": true "dev": true,
"requires": {}
}, },
"acorn-walk": { "acorn-walk": {
"version": "7.2.0", "version": "7.2.0",
@ -18400,13 +18401,15 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true "dev": true,
"requires": {}
}, },
"ajv-keywords": { "ajv-keywords": {
"version": "3.5.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true "dev": true,
"requires": {}
}, },
"alphanum-sort": { "alphanum-sort": {
"version": "1.0.2", "version": "1.0.2",
@ -28209,7 +28212,8 @@
"vuetify": { "vuetify": {
"version": "2.4.11", "version": "2.4.11",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.11.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.11.tgz",
"integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A==" "integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A==",
"requires": {}
}, },
"vuetify-loader": { "vuetify-loader": {
"version": "1.7.2", "version": "1.7.2",
@ -28259,7 +28263,8 @@
"vuex": { "vuex": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz", "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz",
"integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==" "integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==",
"requires": {}
}, },
"vuex-persistedstate": { "vuex-persistedstate": {
"version": "4.0.0-beta.3", "version": "4.0.0-beta.3",

View file

@ -53,6 +53,7 @@ export default {
this.$store.dispatch("requestRecentRecipes"); this.$store.dispatch("requestRecentRecipes");
this.$store.dispatch("refreshToken"); this.$store.dispatch("refreshToken");
this.$store.dispatch("requestCurrentGroup"); this.$store.dispatch("requestCurrentGroup");
this.$store.dispatch("requestUserData");
this.$store.dispatch("requestCategories"); this.$store.dispatch("requestCategories");
this.$store.dispatch("requestTags"); this.$store.dispatch("requestTags");
this.darkModeSystemCheck(); this.darkModeSystemCheck();

View file

@ -49,7 +49,7 @@ const apiReq = {
return handleResponse(response, getSuccessText); return handleResponse(response, getSuccessText);
}, },
get: function(url, data, getErrorText = defaultErrorText) { get: async function(url, data, getErrorText = defaultErrorText) {
return axios.get(url, data).catch(function(error) { return axios.get(url, data).catch(function(error) {
handleError(error, getErrorText); handleError(error, getErrorText);
}); });

View file

@ -54,6 +54,7 @@ export const API_ROUTES = {
categoriesCategory: (category) => `${prefix}/categories/${category}`, categoriesCategory: (category) => `${prefix}/categories/${category}`,
debugLogNum: (num) => `${prefix}/debug/log/${num}`, debugLogNum: (num) => `${prefix}/debug/log/${num}`,
groupsId: (id) => `${prefix}/groups/${id}`, groupsId: (id) => `${prefix}/groups/${id}`,
mealPlansId: (id) => `${prefix}/meal-plans/${id}`,
mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`, mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`,
mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`, mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`,
mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`, mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`,
@ -70,6 +71,8 @@ export const API_ROUTES = {
themesId: (id) => `${prefix}/themes/${id}`, themesId: (id) => `${prefix}/themes/${id}`,
usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_id}`, usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_id}`,
usersId: (id) => `${prefix}/users/${id}`, usersId: (id) => `${prefix}/users/${id}`,
usersIdFavorites: (id) => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id, slug) => `${prefix}/users/${id}/favorites/${slug}`,
usersIdImage: (id) => `${prefix}/users/${id}/image`, usersIdImage: (id) => `${prefix}/users/${id}/image`,
usersIdPassword: (id) => `${prefix}/users/${id}/password`, usersIdPassword: (id) => `${prefix}/users/${id}/password`,
usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`, usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`,

View file

@ -1,4 +1,5 @@
import { baseURL } from "./api-utils"; import { baseURL } from "./api-utils";
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils"; import { apiReq } from "./api-utils";
import axios from "axios"; import axios from "axios";
import i18n from "@/i18n.js"; import i18n from "@/i18n.js";
@ -91,6 +92,27 @@ export const userAPI = {
const response = await apiReq.delete(usersURLs.userAPIDelete(id)); const response = await apiReq.delete(usersURLs.userAPIDelete(id));
return response.data; return response.data;
}, },
/** Adds a Recipe to the users favorites
* @param id
*/
async getFavorites(id) {
const response = await apiReq.get(API_ROUTES.usersIdFavorites(id));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async addFavorite(id, slug) {
const response = await apiReq.post(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async removeFavorite(id, slug) {
const response = await apiReq.delete(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
}; };
const deleteErrorText = response => { const deleteErrorText = response => {

View file

@ -87,8 +87,7 @@ export default {
this.clear(); this.clear();
this.$store.commit("setToken", response.data.access_token); this.$store.commit("setToken", response.data.access_token);
this.$emit("logged-in"); this.$emit("logged-in");
let user = await api.users.self(); this.$store.dispatch("requestUserData");
this.$store.commit("setUserData", user);
} }
this.loading = false; this.loading = false;

View file

@ -106,14 +106,7 @@ export default {
}, },
}; };
}, },
watch: {
value(val) {
console.log(val);
},
},
mounted() {
console.log(this.value);
},
methods: { methods: {
getImage(slug) { getImage(slug) {
if (slug) { if (slug) {
@ -161,7 +154,6 @@ export default {
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description); this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break; break;
} }
console.log("Hello World");
this.customMeal = { name: "", slug: null, description: "" }; this.customMeal = { name: "", slug: null, description: "" };
}, },
}, },

View file

@ -28,9 +28,7 @@ export default {
props: { props: {
mealPlan: Object, mealPlan: Object,
}, },
mounted() {
console.log(this.mealPlan);
},
methods: { methods: {
formatDate(timestamp) { formatDate(timestamp) {
let dateObject = new Date(timestamp); let dateObject = new Date(timestamp);

View file

@ -0,0 +1,54 @@
<template>
<v-btn
small
@click.prevent="toggleFavorite"
v-if="isFavorite || showAlways"
:color="isFavorite && buttonStyle ? 'secondary' : 'primary'"
:icon="!buttonStyle"
:fab="buttonStyle"
>
<v-icon :small="!buttonStyle" color="secondary">
{{ isFavorite ? "mdi-heart" : "mdi-heart-outline" }}
</v-icon>
</v-btn>
</template>
<script>
import { api } from "@/api";
export default {
props: {
slug: {
default: "",
},
showAlways: {
type: Boolean,
default: false,
},
buttonStyle: {
type: Boolean,
default: false,
},
},
computed: {
user() {
return this.$store.getters.getUserData;
},
isFavorite() {
return this.user.favoriteRecipes.indexOf(this.slug) !== -1;
},
},
methods: {
async toggleFavorite() {
if (!this.isFavorite) {
await api.users.addFavorite(this.user.id, this.slug);
} else {
await api.users.removeFavorite(this.user.id, this.slug);
}
this.$store.dispatch("requestUserData");
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -23,6 +23,7 @@
<v-list-item-title class=" mb-1">{{ name }} </v-list-item-title> <v-list-item-title class=" mb-1">{{ name }} </v-list-item-title>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle> <v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<div class="d-flex justify-center align-center"> <div class="d-flex justify-center align-center">
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating <v-rating
color="secondary" color="secondary"
class="ml-auto" class="ml-auto"
@ -42,10 +43,12 @@
</template> </template>
<script> <script>
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import ContextMenu from "@/components/Recipe/ContextMenu"; import ContextMenu from "@/components/Recipe/ContextMenu";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
components: { components: {
FavoriteBadge,
ContextMenu, ContextMenu,
}, },
props: { props: {
@ -71,6 +74,11 @@ export default {
return api.recipes.recipeSmallImage(slug, this.image); return api.recipes.recipeSmallImage(slug, this.image);
}, },
}, },
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
}; };
</script> </script>

View file

@ -23,6 +23,7 @@
</v-card-title> </v-card-title>
<v-card-actions> <v-card-actions>
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<Rating :value="rating" :name="name" :slug="slug" :small="true" /> <Rating :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" />
@ -33,18 +34,14 @@
</template> </template>
<script> <script>
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips"; import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
import ContextMenu from "@/components/Recipe/ContextMenu"; import ContextMenu from "@/components/Recipe/ContextMenu";
import CardImage from "@/components/Recipe/CardImage"; import CardImage from "@/components/Recipe/CardImage";
import Rating from "@/components/Recipe/Parts/Rating"; import Rating from "@/components/Recipe/Parts/Rating";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
components: { components: { FavoriteBadge, RecipeChips, ContextMenu, Rating, CardImage },
RecipeChips,
ContextMenu,
Rating,
CardImage,
},
props: { props: {
name: String, name: String,
slug: String, slug: String,
@ -64,6 +61,11 @@ export default {
fallBackImage: false, fallBackImage: false,
}; };
}, },
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
methods: { methods: {
getImage(slug) { getImage(slug) {
return api.recipes.recipeSmallImage(slug, this.image); return api.recipes.recipeSmallImage(slug, this.image);

View file

@ -95,7 +95,7 @@
</div> </div>
<div v-intersect="bumpList" class="d-flex"> <div v-intersect="bumpList" class="d-flex">
<v-expand-x-transition> <v-expand-x-transition>
<SiteLoader v-if="loading" :loading="loading" :size="150" /> <SiteLoader v-if="loading" :loading="loading" />
</v-expand-x-transition> </v-expand-x-transition>
</div> </div>
</div> </div>

View file

@ -3,7 +3,7 @@
<slot v-bind="{ open, close }"> </slot> <slot v-bind="{ open, close }"> </slot>
<v-dialog <v-dialog
v-model="dialog" v-model="dialog"
:width="isMobile ? undefined : '700'" :width="isMobile ? undefined : '65%'"
:height="isMobile ? undefined : '0'" :height="isMobile ? undefined : '0'"
:fullscreen="isMobile" :fullscreen="isMobile"
content-class="top-dialog" content-class="top-dialog"

View file

@ -1,14 +1,23 @@
<template> <template>
<v-progress-circular class="mx-auto" :width="size / 20" :size="size" color="primary lighten-2" indeterminate> <div class="mx-auto">
<div class="text-center"> <v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
<v-icon :size="size / 2" color="primary lighten-2"> <div class="text-center">
{{ $globals.icons.primary }} <v-icon :size="size.icon" color="primary lighten-2">
</v-icon> {{ $globals.icons.primary }}
<div> </v-icon>
Loading Recipes <div v-if="large" class="text-small">
<slot>
{{ small ? "" : "Loading Recipes" }}
</slot>
</div>
</div> </div>
</v-progress-circular>
<div v-if="!large" class="text-small">
<slot>
{{ small ? "" : "Loading Recipes" }}
</slot>
</div> </div>
</v-progress-circular> </div>
</template> </template>
<script> <script>
@ -17,8 +26,39 @@ export default {
loading: { loading: {
default: true, default: true,
}, },
size: { small: {
default: 200, type: Boolean,
default: false,
},
medium: {
type: Boolean,
default: true,
},
large: {
type: Boolean,
default: false,
},
},
computed: {
size() {
if (this.small) {
return {
width: 2,
icon: 30,
size: 50,
};
} else if (this.large) {
return {
width: 4,
icon: 120,
size: 200,
};
}
return {
width: 3,
icon: 75,
size: 125,
};
}, },
}, },
}; };

View file

@ -15,6 +15,17 @@
<v-list-item-subtitle> {{ user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle> <v-list-item-subtitle> {{ user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
<v-list-item dense v-if="isLoggedIn" :to="`/user/${user.id}/favorites`">
<v-list-item-icon>
<v-icon>
mdi-heart
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> Favorites </v-list-item-title>
</v-list-item-content>
</v-list-item>
</template> </template>
<v-divider></v-divider> <v-divider></v-divider>
@ -30,7 +41,7 @@
<!-- Version List Item --> <!-- Version List Item -->
<v-list nav dense class="fixedBottom" v-if="!isMain"> <v-list nav dense class="fixedBottom" v-if="!isMain">
<v-list-item href="https://github.com/sponsors/hay-kot" target="_target"> <v-list-item href="https://github.com/sponsors/hay-kot" target="_target">
<v-list-item-icon > <v-list-item-icon>
<v-icon color="pink"> <v-icon color="pink">
mdi-heart mdi-heart
</v-icon> </v-icon>

View file

@ -1,7 +1,9 @@
const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const validators = { export const validators = {
data() { data() {
return { return {
emailRule: v => !v || /^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) || this.$t("user.e-mail-must-be-valid"), emailRule: v => !v || EMAIL_REGEX.test(v) || this.$t("user.e-mail-must-be-valid"),
existsRule: value => !!value || this.$t("general.field-required"), existsRule: value => !!value || this.$t("general.field-required"),

View file

@ -163,7 +163,6 @@ export default {
return; return;
} }
this.loading = true; this.loading = true;
const response = await api.users.update(this.user); const response = await api.users.update(this.user);
if (response) { if (response) {
this.$store.commit("setToken", response.data.access_token); this.$store.commit("setToken", response.data.access_token);

View file

@ -1,7 +1,7 @@
<template> <template>
<v-container> <v-container>
<CardSection <CardSection
title-icon="" title-icon="mdi-test"
v-if="siteSettings.showRecent" v-if="siteSettings.showRecent"
:title="$t('page.recent')" :title="$t('page.recent')"
:recipes="recentRecipes" :recipes="recentRecipes"

View file

@ -104,7 +104,6 @@ export default {
async requestMeals() { async requestMeals() {
const response = await api.mealPlans.all(); const response = await api.mealPlans.all();
this.plannedMeals = response.data; this.plannedMeals = response.data;
console.log(this.plannedMeals);
}, },
mealPlanURL(uid) { mealPlanURL(uid) {
return window.location.origin + "/meal-plan?id=" + uid; return window.location.origin + "/meal-plan?id=" + uid;

View file

@ -51,7 +51,6 @@ export default {
} else { } else {
this.mealPlan = await api.mealPlans.thisWeek(); this.mealPlan = await api.mealPlans.thisWeek();
} }
console.log(this.mealPlans);
if (!this.mealPlan) { if (!this.mealPlan) {
utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet")); utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet"));
} }

View file

@ -12,6 +12,7 @@
class="d-print-none" class="d-print-none"
:key="imageKey" :key="imageKey"
> >
<FavoriteBadge class="ma-1" button-style v-if="loggedIn" :slug="recipeDetails.slug" show-always />
<RecipeTimeCard <RecipeTimeCard
:class="isMobile ? undefined : 'force-bottom'" :class="isMobile ? undefined : 'force-bottom'"
:prepTime="recipeDetails.prepTime" :prepTime="recipeDetails.prepTime"
@ -49,6 +50,7 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import VJsoneditor from "v-jsoneditor"; import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "@/components/Recipe/RecipeViewer"; import RecipeViewer from "@/components/Recipe/RecipeViewer";
import PrintView from "@/components/Recipe/PrintView"; import PrintView from "@/components/Recipe/PrintView";
@ -68,6 +70,7 @@ export default {
RecipeTimeCard, RecipeTimeCard,
PrintView, PrintView,
NoRecipe, NoRecipe,
FavoriteBadge,
}, },
mixins: [user], mixins: [user],
inject: { inject: {
@ -127,6 +130,9 @@ export default {
}, },
computed: { computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
isMobile() { isMobile() {
return this.$vuetify.breakpoint.name === "xs"; return this.$vuetify.breakpoint.name === "xs";
}, },

View file

@ -8,7 +8,7 @@
@sort="assignSorted" @sort="assignSorted"
/> />
<v-row class="d-flex"> <v-row class="d-flex">
<SiteLoader class="mx-auto" v-if="loading" :loading="loading" :size="200" /> <SiteLoader class="mx-auto" v-if="loading" :loading="loading" />
</v-row> </v-row>
</v-container> </v-container>
</template> </template>

View file

@ -0,0 +1,49 @@
<template>
<v-container>
<CardSection
:sortable="true"
:title-icon="$globals.icons.user"
:title="userData.username"
:recipes="shownRecipes"
@sort="assignSorted"
/>
</v-container>
</template>
<script>
import { api } from "@/api";
import CardSection from "@/components/UI/CardSection";
export default {
components: {
CardSection,
},
data() {
return {
title: "",
userData: {},
sortedResults: [],
};
},
computed: {
shownRecipes() {
if (this.sortedResults.length > 0) {
return this.sortedResults;
} else {
return this.userData.favoriteRecipes;
}
},
},
async mounted() {
this.userData = await api.users.getFavorites(this.$route.params.id);
this.sortedResults = [];
},
methods: {
assignSorted(val) {
this.sortedResults = val.slice();
},
},
};
</script>
<style></style>

View file

@ -3,11 +3,13 @@ const NewRecipe = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipe
const CustomPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CustomPage"); const CustomPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CustomPage");
const AllRecipes = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/AllRecipes"); const AllRecipes = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/AllRecipes");
const CategoryTagPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CategoryTagPage"); const CategoryTagPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CategoryTagPage");
const Favorites = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/Favorites");
import { api } from "@/api"; import { api } from "@/api";
export const recipeRoutes = [ export const recipeRoutes = [
// Recipes // Recipes
{ path: "/recipes/all", component: AllRecipes }, { path: "/recipes/all", component: AllRecipes },
{ path: "/user/:id/favorites", component: Favorites },
{ path: "/recipes/tag/:tag", component: CategoryTagPage }, { path: "/recipes/tag/:tag", component: CategoryTagPage },
{ path: "/recipes/tag", component: CategoryTagPage }, { path: "/recipes/tag", component: CategoryTagPage },
{ path: "/recipes/category", component: CategoryTagPage }, { path: "/recipes/category", component: CategoryTagPage },

View file

@ -41,7 +41,6 @@ const actions = {
this.commit("setRecentRecipes", hash); this.commit("setRecentRecipes", hash);
}, },
async requestAllRecipes({ getters }) { async requestAllRecipes({ getters }) {
console.log("All Recipes");
const all = getters.getAllRecipes; const all = getters.getAllRecipes;
const payload = await api.recipes.allSummary(all.length, 9999); const payload = await api.recipes.allSummary(all.length, 9999);
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e])); const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));

View file

@ -54,9 +54,11 @@ const mutations = {
}; };
const actions = { const actions = {
async requestUserData({ commit }) { async requestUserData({ getters, commit }) {
const userData = await api.users.self(); if (getters.getIsLoggedIn) {
commit("setUserData", userData); const userData = await api.users.self();
commit("setUserData", userData);
}
}, },
async resetTheme({ commit }) { async resetTheme({ commit }) {

View file

@ -78,7 +78,7 @@ class BaseDocument:
return session.query(self.sql_model).filter_by(**{match_key: match_value}).one() return session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
def get( def get(
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> Union[BaseModel, list[BaseModel]]: ) -> Union[BaseModel, list[BaseModel]]:
"""Retrieves an entry from the database by matching a key/value pair. If no """Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against. key is provided the class objects primary key will be used to match against.
@ -91,6 +91,7 @@ class BaseDocument:
Returns: Returns:
dict or list[dict]: dict or list[dict]:
""" """
if match_key is None: if match_key is None:
match_key = self.primary_key match_key = self.primary_key
@ -103,12 +104,14 @@ class BaseDocument:
else: else:
result = session.query(self.sql_model).filter_by(**{match_key: match_value}).limit(limit).all() result = session.query(self.sql_model).filter_by(**{match_key: match_value}).limit(limit).all()
eff_schema = override_schema or self.schema
if limit == 1: if limit == 1:
try: try:
return self.schema.from_orm(result[0]) return eff_schema.from_orm(result[0])
except IndexError: except IndexError:
return None return None
return [self.schema.from_orm(x) for x in result] return [eff_schema.from_orm(x) for x in result]
def create(self, session: Session, document: dict) -> BaseModel: def create(self, session: Session, document: dict) -> BaseModel:
"""Creates a new database entry for the given SQL Alchemy Model. """Creates a new database entry for the given SQL Alchemy Model.

View file

@ -19,7 +19,7 @@ class EventNotification(SqlAlchemyBase, BaseMixins):
user = Column(Boolean, default=False) user = Column(Boolean, default=False)
def __init__( def __init__(
self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, *args, **kwargs self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, **_
) -> None: ) -> None:
self.name = name self.name = name
self.notification_url = notification_url self.notification_url = notification_url
@ -41,7 +41,7 @@ class Event(SqlAlchemyBase, BaseMixins):
time_stamp = Column(DateTime) time_stamp = Column(DateTime)
category = Column(String) category = Column(String)
def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None: def __init__(self, title, text, time_stamp, category, **_) -> None:
self.title = title self.title = title
self.text = text self.text = text
self.time_stamp = time_stamp self.time_stamp = time_stamp

View file

@ -1,4 +1,5 @@
import sqlalchemy.ext.declarative as dec import sqlalchemy.ext.declarative as dec
from requests import Session
SqlAlchemyBase = dec.declarative_base() SqlAlchemyBase = dec.declarative_base()
@ -6,3 +7,7 @@ SqlAlchemyBase = dec.declarative_base()
class BaseMixins: class BaseMixins:
def update(self, *args, **kwarg): def update(self, *args, **kwarg):
self.__init__(*args, **kwarg) self.__init__(*args, **kwarg)
def get_ref(cls_type, session: Session, match_value: str, match_attr: str = "id"):
eff_ref = getattr(cls_type, match_attr)
return session.query(cls_type).filter(eff_ref == match_value).one_or_none()

View file

@ -68,6 +68,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
date_added = sa.Column(sa.Date, default=date.today) date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime) date_updated = sa.Column(sa.DateTime)
# Favorited By
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, key, name):
assert name != "" assert name != ""
@ -99,8 +103,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
extras: dict = None, extras: dict = None,
assets: list = None, assets: list = None,
settings: dict = None, settings: dict = None,
*args, **_
**kwargs
) -> None: ) -> None:
self.name = name self.name = name
self.description = description self.description = description
@ -138,6 +141,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.date_added = date_added self.date_added = date_added
self.date_updated = datetime.datetime.now() self.date_updated = datetime.datetime.now()
def update(self, *args, **kwargs): def update(self, **_):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions""" """Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
self.__init__(*args, **kwargs) self.__init__(**_)

View file

@ -42,7 +42,7 @@ class CustomPage(SqlAlchemyBase, BaseMixins):
slug = sa.Column(sa.String, nullable=False) slug = sa.Column(sa.String, nullable=False)
categories = orm.relationship("Category", secondary=custom_pages2categories, single_parent=True) categories = orm.relationship("Category", secondary=custom_pages2categories, single_parent=True)
def __init__(self, session=None, name=None, slug=None, position=0, categories=[], *args, **kwargs) -> None: def __init__(self, session=None, name=None, slug=None, position=0, categories=[], **_) -> None:
self.name = name self.name = name
self.slug = slug self.slug = slug
self.position = position self.position = position

View file

@ -9,7 +9,7 @@ class SiteThemeModel(SqlAlchemyBase, BaseMixins):
name = Column(String, nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
colors = orm.relationship("ThemeColorsModel", uselist=False, single_parent=True, cascade="all, delete-orphan") colors = orm.relationship("ThemeColorsModel", uselist=False, single_parent=True, cascade="all, delete-orphan")
def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None: def __init__(self, name: str, colors: dict, **_) -> None:
self.name = name self.name = name
self.colors = ThemeColorsModel(**colors) self.colors = ThemeColorsModel(**colors)

View file

@ -1,6 +1,7 @@
from mealie.core.config import settings from mealie.core.config import settings
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
@ -22,11 +23,7 @@ class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
full_name = Column(String, index=True) full_name = Column(String, index=True)
username = Column( username = Column(String, index=True, unique=True)
String,
index=True,
unique=True,
)
email = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
password = Column(String) password = Column(String)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(Integer, ForeignKey("groups.id"))
@ -36,21 +33,38 @@ class User(SqlAlchemyBase, BaseMixins):
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
) )
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
def __init__( def __init__(
self, session, full_name, email, password, group: str = settings.DEFAULT_GROUP, admin=False, **_ self,
session,
full_name,
email,
password,
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
admin=False,
**_
) -> None: ) -> None:
group = group or settings.DEFAULT_GROUP group = group or settings.DEFAULT_GROUP
favorite_recipes = favorite_recipes or []
self.full_name = full_name self.full_name = full_name
self.email = email self.email = email
self.group = Group.get_ref(session, group) self.group = Group.get_ref(session, group)
self.admin = admin self.admin = admin
self.password = password self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
]
if self.username is None: if self.username is None:
self.username = full_name self.username = full_name
def update(self, full_name, email, group, admin, username, session=None, id=None, password=None, *args, **kwargs): def update(self, full_name, email, group, admin, username, session=None, favorite_recipes=None, password=None, **_):
favorite_recipes = favorite_recipes or []
self.username = username self.username = username
self.full_name = full_name self.full_name = full_name
self.email = email self.email = email
@ -63,6 +77,11 @@ class User(SqlAlchemyBase, BaseMixins):
if password: if password:
self.password = password self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
]
def update_password(self, password): def update_password(self, password):
self.password = password self.password = password

View file

@ -8,7 +8,7 @@ from mealie.core.security import get_password_hash, verify_password
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserInDB, UserOut from mealie.schema.user import ChangePassword, UserBase, UserFavorites, UserIn, UserInDB, UserOut
from mealie.services.events import create_user_event from mealie.services.events import create_user_event
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/users", tags=["Users"])
async def create_user( async def create_user(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
new_user: UserIn, new_user: UserIn,
current_user=Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
): ):
@ -45,24 +45,21 @@ async def get_all_users(
@router.get("/self", response_model=UserOut) @router.get("/self", response_model=UserOut)
async def get_logged_in_user( async def get_logged_in_user(
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
): ):
return current_user.dict() return current_user.dict()
@router.get("/{id}", response_model=UserOut) @router.get("/{id}", response_model=UserOut, dependencies=[Depends(get_current_user)])
async def get_user_by_id( async def get_user_by_id(
id: int, id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
): ):
return db.users.get(session, id) return db.users.get(session, id)
@router.put("/{id}/reset-password") @router.put("/{id}/reset-password", dependencies=[Depends(get_current_user)])
async def reset_user_password( async def reset_user_password(
id: int, id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
): ):
@ -97,11 +94,10 @@ async def get_user_image(id: str):
return False return False
@router.post("/{id}/image") @router.post("/{id}/image", dependencies=[Depends(get_current_user)])
async def update_user_image( async def update_user_image(
id: str, id: str,
profile_image: UploadFile = File(...), profile_image: UploadFile = File(...),
current_user: UserInDB = Depends(get_current_user),
): ):
""" Updates a User Image """ """ Updates a User Image """
@ -139,6 +135,41 @@ async def update_password(
db.users.update_password(session, id, new_password) db.users.update_password(session, id, new_password)
@router.get("/{id}/favorites", response_model=UserFavorites)
async def get_favorites(id: str, session: Session = Depends(generate_session)):
""" Adds a Recipe to the users favorites """
return db.users.get(session, id, override_schema=UserFavorites)
@router.post("/{id}/favorites/{slug}")
async def add_favorite(
slug: str,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Adds a Recipe to the users favorites """
current_user.favorite_recipes.append(slug)
db.users.update(session, current_user.id, current_user)
@router.delete("/{id}/favorites/{slug}")
async def remove_favorite(
slug: str,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Adds a Recipe to the users favorites """
current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug]
db.users.update(session, current_user.id, current_user)
return
@router.delete("/{id}") @router.delete("/{id}")
async def delete_user( async def delete_user(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,

5
mealie/schema/helpers.py Normal file
View file

@ -0,0 +1,5 @@
from fastapi_camelcase import CamelModel
class RecipeSlug(CamelModel):
slug: str

View file

@ -6,6 +6,7 @@ from mealie.db.models.group import Group
from mealie.db.models.users import User from mealie.db.models.users import User
from mealie.schema.category import CategoryBase from mealie.schema.category import CategoryBase
from mealie.schema.meal import MealPlanOut from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import RecipeSummary
from mealie.schema.shopping_list import ShoppingListOut from mealie.schema.shopping_list import ShoppingListOut
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -48,6 +49,7 @@ class UserBase(CamelModel):
email: constr(to_lower=True, strip_whitespace=True) email: constr(to_lower=True, strip_whitespace=True)
admin: bool admin: bool
group: Optional[str] group: Optional[str]
favorite_recipes: Optional[list[str]] = []
class Config: class Config:
orm_mode = True orm_mode = True
@ -76,6 +78,22 @@ class UserOut(UserBase):
id: int id: int
group: str group: str
tokens: Optional[list[LongLiveTokenOut]] tokens: Optional[list[LongLiveTokenOut]]
favorite_recipes: Optional[list[str]] = []
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: User):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
"favorite_recipes": [x.slug for x in ormModel.favorite_recipes],
}
class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = []
class Config: class Config:
orm_mode = True orm_mode = True
@ -90,7 +108,6 @@ class UserOut(UserBase):
class UserInDB(UserOut): class UserInDB(UserOut):
password: str password: str
pass
class Config: class Config:
orm_mode = True orm_mode = True

File diff suppressed because one or more lines are too long