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:
parent
57f7ea3750
commit
6f38fcf81b
38 changed files with 365 additions and 82 deletions
|
@ -74,6 +74,9 @@ class AppRoutes:
|
|||
def groups_id(self, 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):
|
||||
return f"{self.prefix}/meal-plans/{id}/shopping-list"
|
||||
|
||||
|
@ -122,6 +125,12 @@ class AppRoutes:
|
|||
def users_id(self, 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):
|
||||
return f"{self.prefix}/users/{id}/image"
|
||||
|
||||
|
|
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
|
@ -17673,7 +17673,6 @@
|
|||
"integrity": "sha512-8q67ORQ9O0Ms0nlqsXTVhaBefRBaLrzPxOewAZhdcO7onHwcO5/wRdWtHhZgfpCZlhY7NogkU16z3WnorSSkEA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/core": "^7.11.0",
|
||||
"@babel/helper-compilation-targets": "^7.9.6",
|
||||
"@babel/helper-module-imports": "^7.8.3",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
|
@ -17686,7 +17685,6 @@
|
|||
"@vue/babel-plugin-jsx": "^1.0.3",
|
||||
"@vue/babel-preset-jsx": "^1.2.4",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"core-js": "^3.6.5",
|
||||
"core-js-compat": "^3.6.5",
|
||||
"semver": "^6.1.0"
|
||||
}
|
||||
|
@ -17849,7 +17847,8 @@
|
|||
"version": "4.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.12.tgz",
|
||||
"integrity": "sha512-STgbvNv/3iHAKArc18b/qjN7RX1FTrfxPeHH26GOr/A8lJes7+CSluZZ8E5R7Zr/vL0zOqOkUVDAjFXVf4zWQA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@vue/cli-service": {
|
||||
"version": "4.5.12",
|
||||
|
@ -18141,7 +18140,8 @@
|
|||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz",
|
||||
"integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@vue/web-component-wrapper": {
|
||||
"version": "1.3.0",
|
||||
|
@ -18361,7 +18361,8 @@
|
|||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz",
|
||||
"integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"acorn-walk": {
|
||||
"version": "7.2.0",
|
||||
|
@ -18400,13 +18401,15 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
|
||||
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"alphanum-sort": {
|
||||
"version": "1.0.2",
|
||||
|
@ -28209,7 +28212,8 @@
|
|||
"vuetify": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.11.tgz",
|
||||
"integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A=="
|
||||
"integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A==",
|
||||
"requires": {}
|
||||
},
|
||||
"vuetify-loader": {
|
||||
"version": "1.7.2",
|
||||
|
@ -28259,7 +28263,8 @@
|
|||
"vuex": {
|
||||
"version": "3.6.2",
|
||||
"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": {
|
||||
"version": "4.0.0-beta.3",
|
||||
|
|
|
@ -53,6 +53,7 @@ export default {
|
|||
this.$store.dispatch("requestRecentRecipes");
|
||||
this.$store.dispatch("refreshToken");
|
||||
this.$store.dispatch("requestCurrentGroup");
|
||||
this.$store.dispatch("requestUserData");
|
||||
this.$store.dispatch("requestCategories");
|
||||
this.$store.dispatch("requestTags");
|
||||
this.darkModeSystemCheck();
|
||||
|
|
|
@ -49,7 +49,7 @@ const apiReq = {
|
|||
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) {
|
||||
handleError(error, getErrorText);
|
||||
});
|
||||
|
|
|
@ -54,6 +54,7 @@ export const API_ROUTES = {
|
|||
categoriesCategory: (category) => `${prefix}/categories/${category}`,
|
||||
debugLogNum: (num) => `${prefix}/debug/log/${num}`,
|
||||
groupsId: (id) => `${prefix}/groups/${id}`,
|
||||
mealPlansId: (id) => `${prefix}/meal-plans/${id}`,
|
||||
mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`,
|
||||
mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`,
|
||||
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}`,
|
||||
usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_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`,
|
||||
usersIdPassword: (id) => `${prefix}/users/${id}/password`,
|
||||
usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { baseURL } from "./api-utils";
|
||||
import { API_ROUTES } from "./apiRoutes";
|
||||
import { apiReq } from "./api-utils";
|
||||
import axios from "axios";
|
||||
import i18n from "@/i18n.js";
|
||||
|
@ -91,6 +92,27 @@ export const userAPI = {
|
|||
const response = await apiReq.delete(usersURLs.userAPIDelete(id));
|
||||
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 => {
|
||||
|
|
|
@ -87,8 +87,7 @@ export default {
|
|||
this.clear();
|
||||
this.$store.commit("setToken", response.data.access_token);
|
||||
this.$emit("logged-in");
|
||||
let user = await api.users.self();
|
||||
this.$store.commit("setUserData", user);
|
||||
this.$store.dispatch("requestUserData");
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
|
|
@ -106,14 +106,7 @@ export default {
|
|||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
console.log(val);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.value);
|
||||
},
|
||||
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
if (slug) {
|
||||
|
@ -161,7 +154,6 @@ export default {
|
|||
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
|
||||
break;
|
||||
}
|
||||
console.log("Hello World");
|
||||
this.customMeal = { name: "", slug: null, description: "" };
|
||||
},
|
||||
},
|
||||
|
|
|
@ -28,9 +28,7 @@ export default {
|
|||
props: {
|
||||
mealPlan: Object,
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.mealPlan);
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatDate(timestamp) {
|
||||
let dateObject = new Date(timestamp);
|
||||
|
|
54
frontend/src/components/Recipe/FavoriteBadge.vue
Normal file
54
frontend/src/components/Recipe/FavoriteBadge.vue
Normal 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>
|
|
@ -23,6 +23,7 @@
|
|||
<v-list-item-title class=" mb-1">{{ name }} </v-list-item-title>
|
||||
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<v-rating
|
||||
color="secondary"
|
||||
class="ml-auto"
|
||||
|
@ -42,10 +43,12 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
|
||||
import ContextMenu from "@/components/Recipe/ContextMenu";
|
||||
import { api } from "@/api";
|
||||
export default {
|
||||
components: {
|
||||
FavoriteBadge,
|
||||
ContextMenu,
|
||||
},
|
||||
props: {
|
||||
|
@ -71,6 +74,11 @@ export default {
|
|||
return api.recipes.recipeSmallImage(slug, this.image);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<Rating :value="rating" :name="name" :slug="slug" :small="true" />
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" />
|
||||
|
@ -33,18 +34,14 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
|
||||
import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
|
||||
import ContextMenu from "@/components/Recipe/ContextMenu";
|
||||
import CardImage from "@/components/Recipe/CardImage";
|
||||
import Rating from "@/components/Recipe/Parts/Rating";
|
||||
import { api } from "@/api";
|
||||
export default {
|
||||
components: {
|
||||
RecipeChips,
|
||||
ContextMenu,
|
||||
Rating,
|
||||
CardImage,
|
||||
},
|
||||
components: { FavoriteBadge, RecipeChips, ContextMenu, Rating, CardImage },
|
||||
props: {
|
||||
name: String,
|
||||
slug: String,
|
||||
|
@ -64,6 +61,11 @@ export default {
|
|||
fallBackImage: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
return api.recipes.recipeSmallImage(slug, this.image);
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
</div>
|
||||
<div v-intersect="bumpList" class="d-flex">
|
||||
<v-expand-x-transition>
|
||||
<SiteLoader v-if="loading" :loading="loading" :size="150" />
|
||||
<SiteLoader v-if="loading" :loading="loading" />
|
||||
</v-expand-x-transition>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<slot v-bind="{ open, close }"> </slot>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:width="isMobile ? undefined : '700'"
|
||||
:width="isMobile ? undefined : '65%'"
|
||||
:height="isMobile ? undefined : '0'"
|
||||
:fullscreen="isMobile"
|
||||
content-class="top-dialog"
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
<template>
|
||||
<v-progress-circular class="mx-auto" :width="size / 20" :size="size" color="primary lighten-2" indeterminate>
|
||||
<div class="text-center">
|
||||
<v-icon :size="size / 2" color="primary lighten-2">
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<div>
|
||||
Loading Recipes
|
||||
<div class="mx-auto">
|
||||
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
|
||||
<div class="text-center">
|
||||
<v-icon :size="size.icon" color="primary lighten-2">
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<div v-if="large" class="text-small">
|
||||
<slot>
|
||||
{{ small ? "" : "Loading Recipes" }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
<div v-if="!large" class="text-small">
|
||||
<slot>
|
||||
{{ small ? "" : "Loading Recipes" }}
|
||||
</slot>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -17,8 +26,39 @@ export default {
|
|||
loading: {
|
||||
default: true,
|
||||
},
|
||||
size: {
|
||||
default: 200,
|
||||
small: {
|
||||
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,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -15,6 +15,17 @@
|
|||
<v-list-item-subtitle> {{ user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</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>
|
||||
<v-divider></v-divider>
|
||||
|
||||
|
@ -30,7 +41,7 @@
|
|||
<!-- Version List Item -->
|
||||
<v-list nav dense class="fixedBottom" v-if="!isMain">
|
||||
<v-list-item href="https://github.com/sponsors/hay-kot" target="_target">
|
||||
<v-list-item-icon >
|
||||
<v-list-item-icon>
|
||||
<v-icon color="pink">
|
||||
mdi-heart
|
||||
</v-icon>
|
||||
|
|
|
@ -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 = {
|
||||
data() {
|
||||
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"),
|
||||
|
||||
|
|
|
@ -163,7 +163,6 @@ export default {
|
|||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
|
||||
const response = await api.users.update(this.user);
|
||||
if (response) {
|
||||
this.$store.commit("setToken", response.data.access_token);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<CardSection
|
||||
title-icon=""
|
||||
title-icon="mdi-test"
|
||||
v-if="siteSettings.showRecent"
|
||||
:title="$t('page.recent')"
|
||||
:recipes="recentRecipes"
|
||||
|
|
|
@ -104,7 +104,6 @@ export default {
|
|||
async requestMeals() {
|
||||
const response = await api.mealPlans.all();
|
||||
this.plannedMeals = response.data;
|
||||
console.log(this.plannedMeals);
|
||||
},
|
||||
mealPlanURL(uid) {
|
||||
return window.location.origin + "/meal-plan?id=" + uid;
|
||||
|
|
|
@ -51,7 +51,6 @@ export default {
|
|||
} else {
|
||||
this.mealPlan = await api.mealPlans.thisWeek();
|
||||
}
|
||||
console.log(this.mealPlans);
|
||||
if (!this.mealPlan) {
|
||||
utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet"));
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
class="d-print-none"
|
||||
:key="imageKey"
|
||||
>
|
||||
<FavoriteBadge class="ma-1" button-style v-if="loggedIn" :slug="recipeDetails.slug" show-always />
|
||||
<RecipeTimeCard
|
||||
:class="isMobile ? undefined : 'force-bottom'"
|
||||
:prepTime="recipeDetails.prepTime"
|
||||
|
@ -49,6 +50,7 @@
|
|||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
|
||||
import VJsoneditor from "v-jsoneditor";
|
||||
import RecipeViewer from "@/components/Recipe/RecipeViewer";
|
||||
import PrintView from "@/components/Recipe/PrintView";
|
||||
|
@ -68,6 +70,7 @@ export default {
|
|||
RecipeTimeCard,
|
||||
PrintView,
|
||||
NoRecipe,
|
||||
FavoriteBadge,
|
||||
},
|
||||
mixins: [user],
|
||||
inject: {
|
||||
|
@ -127,6 +130,9 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
isMobile() {
|
||||
return this.$vuetify.breakpoint.name === "xs";
|
||||
},
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
@sort="assignSorted"
|
||||
/>
|
||||
<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-container>
|
||||
</template>
|
||||
|
|
49
frontend/src/pages/Recipes/Favorites.vue
Normal file
49
frontend/src/pages/Recipes/Favorites.vue
Normal 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>
|
|
@ -3,11 +3,13 @@ const NewRecipe = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipe
|
|||
const CustomPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CustomPage");
|
||||
const AllRecipes = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/AllRecipes");
|
||||
const CategoryTagPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CategoryTagPage");
|
||||
const Favorites = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/Favorites");
|
||||
import { api } from "@/api";
|
||||
|
||||
export const recipeRoutes = [
|
||||
// Recipes
|
||||
{ path: "/recipes/all", component: AllRecipes },
|
||||
{ path: "/user/:id/favorites", component: Favorites },
|
||||
{ path: "/recipes/tag/:tag", component: CategoryTagPage },
|
||||
{ path: "/recipes/tag", component: CategoryTagPage },
|
||||
{ path: "/recipes/category", component: CategoryTagPage },
|
||||
|
|
|
@ -41,7 +41,6 @@ const actions = {
|
|||
this.commit("setRecentRecipes", hash);
|
||||
},
|
||||
async requestAllRecipes({ getters }) {
|
||||
console.log("All Recipes");
|
||||
const all = getters.getAllRecipes;
|
||||
const payload = await api.recipes.allSummary(all.length, 9999);
|
||||
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));
|
||||
|
|
|
@ -54,9 +54,11 @@ const mutations = {
|
|||
};
|
||||
|
||||
const actions = {
|
||||
async requestUserData({ commit }) {
|
||||
const userData = await api.users.self();
|
||||
commit("setUserData", userData);
|
||||
async requestUserData({ getters, commit }) {
|
||||
if (getters.getIsLoggedIn) {
|
||||
const userData = await api.users.self();
|
||||
commit("setUserData", userData);
|
||||
}
|
||||
},
|
||||
|
||||
async resetTheme({ commit }) {
|
||||
|
|
|
@ -78,7 +78,7 @@ class BaseDocument:
|
|||
return session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
|
||||
|
||||
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]]:
|
||||
"""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.
|
||||
|
@ -91,6 +91,7 @@ class BaseDocument:
|
|||
|
||||
Returns:
|
||||
dict or list[dict]:
|
||||
|
||||
"""
|
||||
if match_key is None:
|
||||
match_key = self.primary_key
|
||||
|
@ -103,12 +104,14 @@ class BaseDocument:
|
|||
else:
|
||||
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:
|
||||
try:
|
||||
return self.schema.from_orm(result[0])
|
||||
return eff_schema.from_orm(result[0])
|
||||
except IndexError:
|
||||
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:
|
||||
"""Creates a new database entry for the given SQL Alchemy Model.
|
||||
|
|
|
@ -19,7 +19,7 @@ class EventNotification(SqlAlchemyBase, BaseMixins):
|
|||
user = Column(Boolean, default=False)
|
||||
|
||||
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:
|
||||
self.name = name
|
||||
self.notification_url = notification_url
|
||||
|
@ -41,7 +41,7 @@ class Event(SqlAlchemyBase, BaseMixins):
|
|||
time_stamp = Column(DateTime)
|
||||
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.text = text
|
||||
self.time_stamp = time_stamp
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import sqlalchemy.ext.declarative as dec
|
||||
from requests import Session
|
||||
|
||||
SqlAlchemyBase = dec.declarative_base()
|
||||
|
||||
|
@ -6,3 +7,7 @@ SqlAlchemyBase = dec.declarative_base()
|
|||
class BaseMixins:
|
||||
def update(self, *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()
|
||||
|
|
|
@ -68,6 +68,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
date_added = sa.Column(sa.Date, default=date.today)
|
||||
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")
|
||||
def validate_name(self, key, name):
|
||||
assert name != ""
|
||||
|
@ -99,8 +103,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
extras: dict = None,
|
||||
assets: list = None,
|
||||
settings: dict = None,
|
||||
*args,
|
||||
**kwargs
|
||||
**_
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
@ -138,6 +141,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
self.date_added = date_added
|
||||
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"""
|
||||
self.__init__(*args, **kwargs)
|
||||
self.__init__(**_)
|
||||
|
|
|
@ -42,7 +42,7 @@ class CustomPage(SqlAlchemyBase, BaseMixins):
|
|||
slug = sa.Column(sa.String, nullable=False)
|
||||
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.slug = slug
|
||||
self.position = position
|
||||
|
|
|
@ -9,7 +9,7 @@ class SiteThemeModel(SqlAlchemyBase, BaseMixins):
|
|||
name = Column(String, nullable=False, unique=True)
|
||||
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.colors = ThemeColorsModel(**colors)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from mealie.core.config import settings
|
||||
from mealie.db.models.group import Group
|
||||
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
|
||||
|
||||
|
||||
|
@ -22,11 +23,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True)
|
||||
full_name = Column(String, index=True)
|
||||
username = Column(
|
||||
String,
|
||||
index=True,
|
||||
unique=True,
|
||||
)
|
||||
username = Column(String, index=True, unique=True)
|
||||
email = Column(String, unique=True, index=True)
|
||||
password = Column(String)
|
||||
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
|
||||
)
|
||||
|
||||
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
|
||||
|
||||
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:
|
||||
|
||||
group = group or settings.DEFAULT_GROUP
|
||||
favorite_recipes = favorite_recipes or []
|
||||
self.full_name = full_name
|
||||
self.email = email
|
||||
self.group = Group.get_ref(session, group)
|
||||
self.admin = admin
|
||||
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:
|
||||
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.full_name = full_name
|
||||
self.email = email
|
||||
|
@ -63,6 +77,11 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
if 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):
|
||||
self.password = password
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ from mealie.core.security import get_password_hash, verify_password
|
|||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
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 sqlalchemy.orm.session import Session
|
||||
|
||||
|
@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/users", tags=["Users"])
|
|||
async def create_user(
|
||||
background_tasks: BackgroundTasks,
|
||||
new_user: UserIn,
|
||||
current_user=Depends(get_current_user),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
|
||||
|
@ -45,24 +45,21 @@ async def get_all_users(
|
|||
@router.get("/self", response_model=UserOut)
|
||||
async def get_logged_in_user(
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
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(
|
||||
id: int,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
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(
|
||||
id: int,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
|
||||
|
@ -97,11 +94,10 @@ async def get_user_image(id: str):
|
|||
return False
|
||||
|
||||
|
||||
@router.post("/{id}/image")
|
||||
@router.post("/{id}/image", dependencies=[Depends(get_current_user)])
|
||||
async def update_user_image(
|
||||
id: str,
|
||||
profile_image: UploadFile = File(...),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
):
|
||||
""" Updates a User Image """
|
||||
|
||||
|
@ -139,6 +135,41 @@ async def update_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}")
|
||||
async def delete_user(
|
||||
background_tasks: BackgroundTasks,
|
||||
|
|
5
mealie/schema/helpers.py
Normal file
5
mealie/schema/helpers.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
class RecipeSlug(CamelModel):
|
||||
slug: str
|
|
@ -6,6 +6,7 @@ from mealie.db.models.group import Group
|
|||
from mealie.db.models.users import User
|
||||
from mealie.schema.category import CategoryBase
|
||||
from mealie.schema.meal import MealPlanOut
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
from mealie.schema.shopping_list import ShoppingListOut
|
||||
from pydantic.types import constr
|
||||
from pydantic.utils import GetterDict
|
||||
|
@ -48,6 +49,7 @@ class UserBase(CamelModel):
|
|||
email: constr(to_lower=True, strip_whitespace=True)
|
||||
admin: bool
|
||||
group: Optional[str]
|
||||
favorite_recipes: Optional[list[str]] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
@ -76,6 +78,22 @@ class UserOut(UserBase):
|
|||
id: int
|
||||
group: str
|
||||
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:
|
||||
orm_mode = True
|
||||
|
@ -90,7 +108,6 @@ class UserOut(UserBase):
|
|||
|
||||
class UserInDB(UserOut):
|
||||
password: str
|
||||
pass
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue