General Frontend Bug Fixes for v0.4.0 RC (#233)

* comment

* add frontend-build command

* address #211

* fix margins

* fix import bug

* await user updates

* fix meal-plan filter

* meal-plan search redesign

* improve mobile search

* fix sidebar update

* fix category auto-completes

* draft new pages

* fix tag auto completes

* refactor export const

* dispatch evens for CRUD operations

* recipe loaders screen

* create category dialog

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-03-30 18:12:48 -08:00 committed by GitHub
commit c8284eaeee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 440 additions and 165 deletions

36
.gitignore vendored
View file

@ -156,3 +156,39 @@ mealie/data/debug/last_recipe.json
*.sqlite
dev/data/db/test.db
scratch.py
frontend/dist/favicon.ico
frontend/dist/index.html
frontend/dist/css/app.29fe0155.css
frontend/dist/css/chunk-vendors.db944396.css
frontend/dist/fonts/materialdesignicons-webfont.7a44ea19.woff2
frontend/dist/fonts/materialdesignicons-webfont.64d4cf64.eot
frontend/dist/fonts/materialdesignicons-webfont.147e3378.woff
frontend/dist/fonts/materialdesignicons-webfont.174c02fc.ttf
frontend/dist/fonts/roboto-latin-100.5cb7edfc.woff
frontend/dist/fonts/roboto-latin-100.7370c367.woff2
frontend/dist/fonts/roboto-latin-100italic.f8b1df51.woff2
frontend/dist/fonts/roboto-latin-100italic.f9e8e590.woff
frontend/dist/fonts/roboto-latin-300.b00849e0.woff
frontend/dist/fonts/roboto-latin-300.ef7c6637.woff2
frontend/dist/fonts/roboto-latin-300italic.4df32891.woff
frontend/dist/fonts/roboto-latin-300italic.14286f3b.woff2
frontend/dist/fonts/roboto-latin-400.60fa3c06.woff
frontend/dist/fonts/roboto-latin-400.479970ff.woff2
frontend/dist/fonts/roboto-latin-400italic.51521a2a.woff2
frontend/dist/fonts/roboto-latin-400italic.fe65b833.woff
frontend/dist/fonts/roboto-latin-500.020c97dc.woff2
frontend/dist/fonts/roboto-latin-500.87284894.woff
frontend/dist/fonts/roboto-latin-500italic.288ad9c6.woff
frontend/dist/fonts/roboto-latin-500italic.db4a2a23.woff2
frontend/dist/fonts/roboto-latin-700.2735a3a6.woff2
frontend/dist/fonts/roboto-latin-700.adcde98f.woff
frontend/dist/fonts/roboto-latin-700italic.81f57861.woff
frontend/dist/fonts/roboto-latin-700italic.da0e7178.woff2
frontend/dist/fonts/roboto-latin-900.9b3766ef.woff2
frontend/dist/fonts/roboto-latin-900.bb1e4dc6.woff
frontend/dist/fonts/roboto-latin-900italic.28f91510.woff
frontend/dist/fonts/roboto-latin-900italic.ebf6d164.woff2
frontend/dist/js/app.36f2760c.js
frontend/dist/js/app.36f2760c.js.map
frontend/dist/js/chunk-vendors.c93761e4.js
frontend/dist/js/chunk-vendors.c93761e4.js.map

View file

@ -55,8 +55,7 @@ services:
| TZ | UTC | Must be set to get correct date/time on the server |
## Deployed as a Python Application
Alternatively, this project is built on Python and SQLite. If you are dead set on deploying on a linux machine you can run this in an python virtual env. Provided that you know thats how you want to host the application, I'll assume you know how to do that. I may or may not get around to writing this guide. I'm open to pull requests if anyone has a good guide on it.
## Advanced
!!! warning "Not Required"
@ -89,3 +88,28 @@ The Docker image provided by Mealie contains both the API and the html bundle in
}
```
## Deployed without Docker
!!! error "Unsupported Deployment"
If you are experiencing a problem with manual deployment, please do not submit a github issue unless it is related to an aspect of the application. For deployment help, the [discord server](https://discord.gg/R6QDyJgbD2) is a better place to find support.
Alternatively, this project is built on Python and SQLite so you may run it as a python application on your server. This is not a supported options for deployment and is only here as a reference for those who would like to do this on their own. To get started you can clone this repository into a directory of your choice and use the instructions below as a reference for how to get started.
There are three parts to the Mealie application
- Frontend/Static Files
- Backend API
- Proxy Server
### Frontend/ Static Files
The frontend static files are generated with `npm run build`. This is done during the build process with docker. If you choose to deploy this as a system application you must do this process yourself. In the project directory run `cd frontend` to change directories into the frontend directory and run `npm install` and then `npm run build`. This will generate the static files in a `dist` folder in the frontend directory.
### Backend API
The backend API is build with Python, FastAPI, and SQLite and requires Python 3.9, and Poetry. Once the requirements are installed, in the project directory you can run the command `poetry install` to create a python virtual environment and install the python dependencies.
Once the dependencies are installed you should be ready to run the server. To initialize that database you need to first run `python mealie/db/init_db.py`. Then to start The web server, you run the command `uvicorn mealie.app:app --host 0.0.0.0 --port 9000`
### Proxy Server
You must use a proxy server to server up the static files created with `npm run build` and proxy requests to the API. In the docker build this is done with Caddy. You can use the CaddyFile in the section above as a reference. One important thing to keep in mind is that you should drop any trailing `/` in the url. Not doing this may result in failed API requests.

View file

@ -0,0 +1,4 @@
# Organizing Recipes
!!! tip
Below is a suggestion of guidelines my wife and I use for organizing our recipes within Mealie. Mealie is fairly flexible, so feel free to utilize how you'd like! 👍

View file

@ -1,5 +1,8 @@
# Building Pages
!!! warning
The page building is still experimental and may change. You can provide feedback on any changes you'd like to see on [github](https://github.com/hay-kot/mealie/discussions/229)
Custom pages can be created to organize multiple categories into a single page. Links to your custom pages are displayed on the home page sidebar and accessible by all users, however only Administrators can create or update pages.
To create a new page. Navigate to the settings page at `/admin/settings` and scroll down to the custom pages section. Here you can create, view, and edit your custom pages. To reorder how they are displayed on the sidebar you can drag and drop the pages into the preferred order.

View file

@ -39,6 +39,7 @@ nav:
- Installation: "getting-started/install.md"
- Updating: "getting-started/updating.md"
- Working With Recipes: "getting-started/recipes.md"
- Organizing Recipes: "getting-started/organizing-recipes.md"
- Planning Meals: "getting-started/meal-planner.md"
- iOS Shortcuts: "getting-started/ios.md"
- API Usage: "getting-started/api-usage.md"

View file

@ -1,13 +1,13 @@
<template>
<v-app>
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none">
<router-link to="/">
<router-link v-if="!(isMobile && search)" to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div btn class="pl-2">
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
@ -20,6 +20,7 @@
v-if="search"
:show-results="true"
@selected="navigateFromSearch"
:max-width="isMobile ? '100%' : '450px'"
/>
</v-expand-x-transition>
<v-btn icon @click="search = !search">
@ -64,6 +65,12 @@ export default {
this.search = false;
},
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
created() {
window.addEventListener("keyup", e => {
if (e.key == "/" && !document.activeElement.id.startsWith("input")) {
@ -79,6 +86,7 @@ export default {
this.$store.dispatch("refreshToken");
this.$store.dispatch("requestCurrentGroup");
this.$store.dispatch("requestCategories");
this.$store.dispatch("requestTags");
this.darkModeSystemCheck();
this.darkModeAddEventListener();
},

View file

@ -63,5 +63,7 @@ const apiReq = {
},
};
export { apiReq };
export { baseURL };

View file

@ -1,6 +1,6 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
import { store } from "../store";
import { store } from "@/store";
const backupBase = baseURL + "backups/";
@ -13,7 +13,7 @@ const backupURLs = {
downloadBackup: fileName => `${backupBase}${fileName}/download`,
};
export default {
export const backupAPI = {
/**
* Request all backups available on the server
* @returns {Array} List of Available Backups

View file

@ -1,5 +1,6 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
import { store } from "@/store";
const prefix = baseURL + "categories";
@ -9,17 +10,47 @@ const categoryURLs = {
delete_category: category => `${prefix}/${category}`,
};
export default {
export const categoryAPI = {
async getAll() {
let response = await apiReq.get(categoryURLs.get_all);
return response.data;
},
async get_recipes_in_category(category) {
async create(name) {
let response = await apiReq.post(categoryURLs.get_all, { name: name });
store.dispatch("requestCategories");
return response.data;
},
async getRecipesInCategory(category) {
let response = await apiReq.get(categoryURLs.get_category(category));
return response.data;
},
async delete(category) {
let response = await apiReq.delete(categoryURLs.delete_category(category));
store.dispatch("requestCategories");
return response.data;
},
};
const tagPrefix = baseURL + "tags";
const tagURLs = {
getAll: `${tagPrefix}`,
getTag: tag => `${tagPrefix}/${tag}`,
deleteTag: tag => `${tagPrefix}/${tag}`,
};
export const tagAPI = {
async getAll() {
let response = await apiReq.get(tagURLs.getAll);
return response.data;
},
async getRecipesInTag(tag) {
let response = await apiReq.get(tagURLs.getTag(tag));
return response.data;
},
async delete(tag) {
let response = await apiReq.delete(tagURLs.deleteTag(tag));
store.dispatch("requestTags");
return response.data;
},
};

View file

@ -10,7 +10,7 @@ const groupsURLs = {
update: id => `${groupPrefix}/${id}`,
};
export default {
export const groupAPI = {
async allGroups() {
let response = await apiReq.get(groupsURLs.groups);
return response.data;

View file

@ -1,32 +1,33 @@
import backup from "./backup";
import recipe from "./recipe";
import mealplan from "./mealplan";
import settings from "./settings";
import themes from "./themes";
import migration from "./migration";
import myUtils from "./upload";
import category from "./category";
import meta from "./meta";
import users from "./users";
import signUps from "./signUps";
import groups from "./groups";
import siteSettings from "./siteSettings";
import { backupAPI } from "./backup";
import { recipeAPI } from "./recipe";
import { mealplanAPI } from "./mealplan";
import { settingsAPI } from "./settings";
import { themeAPI } from "./themes";
import { migrationAPI } from "./migration";
import { utilsAPI } from "./upload";
import { categoryAPI, tagAPI } from "./category";
import { metaAPI } from "./meta";
import { userAPI } from "./users";
import { signupAPI } from "./signUps";
import { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings";
/**
* The main object namespace for interacting with the backend database
*/
export const api = {
recipes: recipe,
siteSettings: siteSettings,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
categories: category,
meta: meta,
users: users,
signUps: signUps,
groups: groups,
recipes: recipeAPI,
siteSettings: siteSettingsAPI,
backups: backupAPI,
mealPlans: mealplanAPI,
settings: settingsAPI,
themes: themeAPI,
migrations: migrationAPI,
utils: utilsAPI,
categories: categoryAPI,
tags: tagAPI,
meta: metaAPI,
users: userAPI,
signUps: signupAPI,
groups: groupAPI,
};

View file

@ -14,7 +14,7 @@ const mealPlanURLs = {
shopping: planID => `${prefix}${planID}/shopping-list`,
};
export default {
export const mealplanAPI = {
async create(postBody) {
let response = await apiReq.post(mealPlanURLs.create, postBody);
return response;

View file

@ -8,7 +8,7 @@ const debugURLs = {
lastRecipe: `${prefix}/last-recipe-json`,
};
export default {
export const metaAPI = {
async get_version() {
let response = await apiReq.get(debugURLs.version);
return response.data;

View file

@ -11,7 +11,7 @@ const migrationURLs = {
import: (folder, file) => `${migrationBase}/${folder}/${file}/import`,
};
export default {
export const migrationAPI = {
async getMigrations() {
let response = await apiReq.get(migrationURLs.all);
return response.data;

View file

@ -18,7 +18,7 @@ const recipeURLs = {
updateImage: slug => `${prefix}${slug}/image`,
};
export default {
export const recipeAPI = {
/**
* Create a Recipe by URL
* @param {string} recipeURL
@ -43,6 +43,7 @@ export default {
async create(recipeData) {
let response = await apiReq.post(recipeURLs.create, recipeData);
store.dispatch("requestRecentRecipes");
return response.data;
},
@ -62,15 +63,13 @@ export default {
},
async update(data) {
const recipeSlug = data.slug;
let response = await apiReq.put(recipeURLs.update(recipeSlug), data);
let response = await apiReq.put(recipeURLs.update(data.slug), data);
store.dispatch("requestRecentRecipes");
return response.data;
},
async delete(recipeSlug) {
apiReq.delete(recipeURLs.delete(recipeSlug));
await apiReq.delete(recipeURLs.delete(recipeSlug));
store.dispatch("requestRecentRecipes");
router.push(`/`);
},

View file

@ -9,7 +9,7 @@ const settingsURLs = {
testWebhooks: `${settingsBase}/webhooks/test`,
};
export default {
export const settingsAPI = {
async requestAll() {
let response = await apiReq.get(settingsURLs.siteSettings);
return response.data;

View file

@ -10,7 +10,7 @@ const signUpURLs = {
createUser: token => `${signUpPrefix}/${token}`,
};
export default {
export const signupAPI = {
async getAll() {
let response = await apiReq.get(signUpURLs.all);
return response.data;

View file

@ -11,7 +11,7 @@ const settingsURLs = {
customPage: id => `${settingsBase}/custom-pages/${id}`,
};
export default {
export const siteSettingsAPI = {
async get() {
let response = await apiReq.get(settingsURLs.siteSettings);
return response.data;

View file

@ -11,7 +11,7 @@ const settingsURLs = {
deleteTheme: themeName => `${prefix}/${themeName}`,
};
export default {
export const themeAPI = {
async requestAll() {
let response = await apiReq.get(settingsURLs.allThemes);
return response.data;

View file

@ -1,6 +1,6 @@
import { apiReq } from "./api-utils";
export default {
export const utilsAPI = {
// import { api } from "@/api";
async uploadFile(url, fileObject) {
let response = await apiReq.post(url, fileObject, {

View file

@ -17,7 +17,7 @@ const usersURLs = {
resetPassword: id => `${userPrefix}/${id}/reset-password`,
};
export default {
export const userAPI = {
async login(formData) {
let response = await apiReq.post(authURLs.token, formData, {
headers: {

View file

@ -84,6 +84,7 @@
</v-toolbar-title>
<v-spacer></v-spacer>
<NewCategoryDialog />
</v-app-bar>
<v-list height="300" dense style="overflow:auto">
<v-list-item-group>
@ -132,11 +133,13 @@
import { api } from "@/api";
import LanguageMenu from "@/components/UI/LanguageMenu";
import draggable from "vuedraggable";
import NewCategoryDialog from "./NewCategoryDialog.vue";
export default {
components: {
draggable,
LanguageMenu,
NewCategoryDialog,
},
data() {
return {

View file

@ -0,0 +1,78 @@
<template>
<div>
<v-btn icon @click="dialog = true">
<v-icon color="white">mdi-plus</v-icon>
</v-btn>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
mdi-tag
</v-icon>
<v-toolbar-title class="headline">
Create a Category
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-form @submit.prevent="select">
<v-card-text>
<v-text-field
dense
label="Category Name"
v-model="categoryName"
:rules="[rules.required]"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="dialog = false">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="success" text type="submit" :disabled="!categoryName">
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { api } from "@/api";
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
categoryName: "",
rules: {
required: val =>
!!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
watch: {
dialog(val) {
if (!val) this.categoryName = "";
},
},
methods: {
async select() {
await api.categories.create(this.categoryName);
this.$emit("new-category", this.categoryName);
this.dialog = false;
},
},
};
</script>
<style>
</style>

View file

@ -22,7 +22,7 @@
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<v-dialog v-model="dialog" max-width="500">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
{{ $t("user.create-link") }}
@ -42,19 +42,16 @@
</v-app-bar>
<v-form ref="newUser" @submit.prevent="save">
<v-card-text>
<v-row class="justify-center mt-3">
<v-text-field
class="mr-2"
v-model="editedItem.name"
:label="$t('user.link-name')"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
<v-checkbox
v-model="editedItem.admin"
:label="$t('user.admin')"
></v-checkbox>
</v-row>
<v-text-field
v-model="editedItem.name"
:label="$t('user.link-name')"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
<v-checkbox
v-model="editedItem.admin"
:label="$t('user.admin')"
></v-checkbox>
</v-card-text>
<v-card-actions>

View file

@ -264,10 +264,10 @@ export default {
async save() {
if (this.editedIndex > -1) {
api.users.update(this.editedItem);
await api.users.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.users.create(this.editedItem);
await api.users.create(this.editedItem);
this.close();
}
await this.initialize();

View file

@ -115,26 +115,17 @@ export default {
});
}
},
groupSettings() {
this.buildMealStore();
},
},
async mounted() {
let categories = Array.from(this.groupSettings, x => x.name);
this.items = await api.recipes.getAllByCategory(categories);
if (this.items.length === 0) {
const keys = [
"name",
"slug",
"image",
"description",
"dateAdded",
"rating",
];
this.items = await api.recipes.allByKeys(keys);
}
this.$store.dispatch("requestCurrentGroup");
},
computed: {
groupSettings() {
console.log(this.$store.getters.getCurrentGroup);
return this.$store.getters.getCurrentGroup;
},
actualStartDate() {
@ -164,6 +155,22 @@ export default {
},
methods: {
async buildMealStore() {
let categories = Array.from(this.groupSettings.categories, x => x.name);
this.items = await api.recipes.getAllByCategory(categories);
if (this.items.length === 0) {
const keys = [
"name",
"slug",
"image",
"description",
"dateAdded",
"rating",
];
this.items = await api.recipes.allByKeys(keys);
}
},
get_random(list) {
const object = list[Math.floor(Math.random() * list.length)];
return object;

View file

@ -120,17 +120,19 @@
deletable-chips
v-model="value.recipeCategory"
hide-selected
:items="categories"
:items="allCategories"
text="name"
:search-input.sync="categoriesSearchInput"
@change="categoriesSearchInput = ''"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
@click:close="removeCategory(data.index)"
color="secondary"
label
color="accent"
dark
>
{{ data.item }}
@ -146,16 +148,18 @@
deletable-chips
v-model="value.tags"
hide-selected
:items="tags"
:items="allTags"
:search-input.sync="tagsSearchInput"
@change="tagssSearchInput = ''"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
label
@click:close="removeTags(data.index)"
color="secondary"
color="accent"
dark
>
{{ data.item }}
@ -221,7 +225,7 @@
elevation="0"
@click="removeStep(index)"
>
<v-icon color="error">mdi-delete</v-icon>
<v-icon size="24" color="error">mdi-delete</v-icon>
</v-btn>
{{ $t("recipe.step-index", { step: index + 1 }) }}
</v-card-title>
@ -280,18 +284,19 @@ export default {
},
categoriesSearchInput: "",
tagsSearchInput: "",
categories: [],
tags: [],
};
},
mounted() {
this.getCategories();
computed: {
allCategories() {
const categories = this.$store.getters.getAllCategories;
return categories.map(cat => cat.name);
},
allTags() {
const tags = this.$store.getters.getAllTags;
return tags.map(cat => cat.name);
},
},
methods: {
async getCategories() {
let response = await api.categories.get_all();
this.categories = response.map(cat => cat.name);
},
uploadImage() {
this.$emit("upload", this.fileObject);
},

View file

@ -2,6 +2,8 @@
<div v-if="items && items.length > 0">
<h2 class="mt-4">{{ title }}</h2>
<v-chip
:to="`/recipes/${getSlug(category)}`"
label
class="ma-1"
color="accent"
dark
@ -18,6 +20,21 @@ export default {
props: {
items: Array,
title: String,
category: {
default: true,
},
},
computed: {
allCategories() {
return this.$store.getters.getAllCategories;
},
},
methods: {
getSlug(name) {
if (this.category) {
return this.allCategories.filter(x => x.name == name)[0].slug;
}
},
},
};
</script>

View file

@ -1,34 +1,38 @@
<template>
<v-menu v-model="menuModel" offset-y readonly max-width="450">
<v-menu v-model="menuModel" offset-y readonly :width="maxWidth">
<template #activator="{ attrs }">
<v-text-field
class="mt-6"
v-model="search"
v-bind="attrs"
dense
:dense="dense"
light
:label="$t('search.search-mealie')"
solo
autofocus
style="max-width: 450px;"
:solo="solo"
:style="`max-width: ${maxWidth};`"
@focus="onFocus"
autocomplete="off"
>
</v-text-field>
</template>
<v-card v-if="showResults" max-height="500" min-width="98%" class="">
<v-card-text class="py-1">Results</v-card-text>
<v-card v-if="showResults" max-height="500" :max-width="maxWidth">
<v-card-text class="py-1">Results</v-card-text>
<v-divider></v-divider>
<v-list scrollable>
<v-list-item
v-for="(item, index) in autoResults"
:key="index"
:to="showResults ? `/recipe/${item.item.slug}` : null"
:to="navOnClick ? `/recipe/${item.item.slug}` : null"
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
>
<v-list-item-avatar>
<v-img :src="getImage(item.item.image)"></v-img>
</v-list-item-avatar>
<v-list-item-content
@click="showResults ? null : selected(item.item.slug)"
@click="
showResults ? null : selected(item.item.slug, item.item.name)
"
>
<v-list-item-title v-html="highlight(item.item.name)">
</v-list-item-title>
@ -57,6 +61,21 @@ export default {
showResults: {
default: false,
},
maxWidth: {
default: "450px",
},
dense: {
default: true,
},
navOnClick: {
default: true,
},
resetSearch: {
default: false,
},
solo: {
default: true,
},
},
data() {
return {
@ -65,7 +84,7 @@ export default {
menuModel: false,
data: [],
result: [],
autoResults: [],
fuseResults: [],
isDark: false,
options: {
shouldSort: true,
@ -84,6 +103,9 @@ export default {
this.data = this.$store.getters.getRecentRecipes;
},
computed: {
autoResults() {
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
},
fuse() {
return new Fuse(this.data, this.options);
},
@ -96,6 +118,10 @@ export default {
val ? (this.menuModel = true) : null;
},
resetSearch(val) {
val ? (this.search = "") : null;
},
search() {
try {
this.result = this.fuse.search(this.search.trim());
@ -107,7 +133,7 @@ export default {
this.$emit("results", this.result);
if (this.showResults === true) {
this.autoResults = this.result;
this.fuseResults = this.result;
}
},
searchSlug() {
@ -127,8 +153,9 @@ export default {
getImage(image) {
return utils.getImageURL(image);
},
selected(slug) {
this.$emit("selected", slug);
selected(slug, name) {
console.log("Selected", slug, name);
this.$emit("selected", slug, name);
},
async onFocus() {
clearTimeout(this.timeout);

View file

@ -1,41 +1,21 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" height="100%" max-width="1200">
<v-card min-height="725" height="100%">
<v-dialog v-model="dialog" width="600px" height="0">
<v-card>
<v-app-bar dark color="primary">
<v-toolbar-title class="headline">Search a Recipe</v-toolbar-title>
</v-app-bar>
<v-card-text>
<v-card-title></v-card-title>
<v-row justify="center">
<v-col cols="1"> </v-col>
<v-col>
<SearchBar @results="updateResults" :show-results="false" />
</v-col>
<v-col cols="2">
<v-btn icon>
<v-icon large> mdi-filter </v-icon>
</v-btn>
</v-col>
</v-row>
<v-row v-if="searchResults">
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="item in searchResults.slice(0, 24)"
:key="item.item.name"
>
<RecipeCard
:route="false"
:name="item.item.name"
:description="item.item.description"
:slug="item.item.slug"
:rating="item.item.rating"
:image="item.item.image"
@click="emitSelect(item.item.name, item.item.slug)"
/>
</v-col>
</v-row>
<SearchBar
@results="updateResults"
@selected="emitSelect"
:show-results="true"
max-width="550px"
:dense="false"
:nav-on-click="false"
:reset-search="dialog"
:solo="false"
/>
</v-card-text>
</v-card>
</v-dialog>
@ -44,11 +24,9 @@
<script>
import SearchBar from "./SearchBar";
import RecipeCard from "../../Recipe/RecipeCard";
export default {
components: {
SearchBar,
RecipeCard,
},
data() {
return {
@ -60,7 +38,7 @@ export default {
updateResults(results) {
this.searchResults = results;
},
emitSelect(name, slug) {
emitSelect(slug, name) {
this.$emit("select", name, slug);
this.dialog = false;
},
@ -72,4 +50,9 @@ export default {
</script>
<style>
.v-dialog__content {
margin-top: 10%;
align-items: flex-start;
justify-content: center;
}
</style>

View file

@ -133,7 +133,7 @@ export default {
},
computed: {
categories() {
return this.$store.getters.getCategories;
return this.$store.getters.getAllCategories;
},
isFlat() {
return this.groupSettings.categories >= 1 ? true : false;

View file

@ -201,6 +201,7 @@ export default {
this.$store.commit("setToken", newKey.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData")
},
async changePassword() {
this.paswordLoading = true;

View file

@ -59,7 +59,7 @@ export default {
});
},
async getRecipeByCategory(category) {
return await api.categories.get_recipes_in_category(category);
return await api.categories.getRecipesInCategory(category);
},
getRecentRecipes() {
this.$store.dispatch("requestRecentRecipes");

View file

@ -1,6 +1,17 @@
<template>
<v-container>
<v-card id="myRecipe">
<v-card
v-if="skeleton"
:color="`white ${theme.isDark ? '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 id="myRecipe">
<v-img
height="400"
:src="getImage(recipeDetails.image)"
@ -77,8 +88,14 @@ export default {
RecipeTimeCard,
},
mixins: [user],
inject: {
theme: {
default: { isDark: false },
},
},
data() {
return {
skeleton: true,
// currentRecipe: this.$route.params.recipe,
form: false,
jsonEditor: false,
@ -138,6 +155,7 @@ export default {
},
async getRecipeDetails() {
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
this.skeleton = false;
this.form = false;
},
getImage(image) {

View file

@ -42,7 +42,7 @@ export default {
},
methods: {
async getRecipes() {
let data = await api.categories.get_recipes_in_category(
let data = await api.categories.getRecipesInCategory(
this.currentCategory
);
this.title = data.name;

View file

@ -76,7 +76,7 @@ export default {
});
},
async getRecipeByCategory(category) {
return await api.categories.get_recipes_in_category(category);
return await api.categories.getRecipesInCategory(category);
},
filterRecipe(slug) {
const storeCategory = this.recipeStore.find(

View file

@ -27,19 +27,22 @@ const store = new Vuex.Store({
allRecipes: [],
mealPlanCategories: [],
allCategories: [],
allTags: [],
},
mutations: {
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
setMealPlanCategories(state, payload) {
state.mealPlanCategories = payload;
},
setAllCategories(state, payload) {
state.allCategories = payload;
},
setAllTags(state, payload) {
state.allTags = payload;
},
},
actions: {
@ -60,12 +63,19 @@ const store = new Vuex.Store({
const categories = await api.categories.getAll();
commit("setAllCategories", categories);
},
async requestTags({ commit }) {
const tags = await api.tags.getAll();
commit("setAllTags", tags);
},
},
getters: {
getRecentRecipes: state => state.recentRecipes,
getMealPlanCategories: state => state.mealPlanCategories,
getAllCategories: state => state.allCategories,
getAllCategories: state =>
state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
getAllTags: state =>
state.allTags.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
},
});

View file

@ -54,6 +54,11 @@ const mutations = {
};
const actions = {
async requestUserData({ commit }) {
const userData = await api.users.self();
commit("setUserData", userData);
},
async resetTheme({ commit }) {
const defaultTheme = await api.themes.requestByName("default");
if (defaultTheme.colors) {

View file

@ -56,10 +56,14 @@ backend: ## Start Mealie Backend Development Server
poetry run python mealie/db/init_db.py && \
poetry run python mealie/app.py
.PHONY: frontend
frontend: ## Start Mealie Frontend Development Server
cd frontend && npm run serve
frontend-build: ## Build Frontend in frontend/dist
cd frontned && npm run build
.PHONY: docs
docs: ## Start Mkdocs Development Server
poetry run python dev/scripts/api_docs_gen.py && \
@ -71,7 +75,6 @@ docker-dev: ## Build and Start Docker Development Stack
docker-prod: ## Build and Start Docker Production Stack
docker-compose -p mealie up --build -d
code-gen: ## Run Code-Gen Scripts
poetry run python dev/scripts/app_routes_gen.py

View file

@ -1,7 +1,7 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from mealie.db.models.model_base import SqlAlchemyBase
from fastapi.logger import logger
from mealie.db.models.model_base import SqlAlchemyBase
from slugify import slugify
from sqlalchemy.orm import validates
@ -46,7 +46,7 @@ class Category(SqlAlchemyBase):
assert name != ""
return name
def __init__(self, name) -> None:
def __init__(self, name, session=None) -> None:
self.name = name.strip()
self.slug = slugify(name)

View file

@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depend
def available_imports():
"""Returns a list of avaiable .zip files for import into Mealie."""
imports = []
for archive in app_dirs.app_dirs.BACKUP_DIR.glob("*.zip"):
for archive in app_dirs.BACKUP_DIR.glob("*.zip"):
backup = LocalBackup(name=archive.name, date=archive.stat().st_ctime)
imports.append(backup)

View file

@ -1,8 +1,8 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from fastapi import APIRouter, Depends, Query
from mealie.schema.recipe import AllRecipeRequest
from slugify import slugify
from sqlalchemy.orm.session import Session
@ -75,7 +75,7 @@ def filter_by_category(categories: list, session: Session = Depends(generate_ses
""" pass a list of categories and get a list of recipes associated with those categories """
# ! This should be refactored into a single database call, but I couldn't figure it out
in_category = [db.categories.get(session, slugify(cat), limit=1) for cat in categories]
in_category = [cat.get("recipes") for cat in in_category if cat]
in_category = [cat.recipes for cat in in_category if cat]
in_category = [item for sublist in in_category for item in sublist]
return in_category
@ -85,6 +85,6 @@ async def filter_by_tags(tags: list, session: Session = Depends(generate_session
""" pass a list of tags and get a list of recipes associated with those tags"""
# ! This should be refactored into a single database call, but I couldn't figure it out
in_tags = [db.tags.get(session, slugify(tag), limit=1) for tag in tags]
in_tags = [tag.get("recipes") for tag in in_tags]
in_tags = [tag.recipes for tag in in_tags]
in_tags = [item for sublist in in_tags for item in sublist]
return in_tags

View file

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
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.category import RecipeCategoryResponse
from mealie.schema.category import CategoryIn, RecipeCategoryResponse
from mealie.schema.snackbar import SnackResponse
from sqlalchemy.orm.session import Session
@ -18,6 +18,15 @@ async def get_all_recipe_categories(session: Session = Depends(generate_session)
return db.categories.get_all_limit_columns(session, ["slug", "name"])
@router.post("")
async def create_recipe_category(
category: CategoryIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
):
""" Creates a Category in the database """
return db.categories.create(session, category.dict())
@router.get("/{category}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(category: str, session: Session = Depends(generate_session)):
""" Returns a list of recipes associated with the provided category. """

View file

@ -8,15 +8,15 @@ from sqlalchemy.orm.session import Session
router = APIRouter(tags=["Recipes"])
router = APIRouter(
prefix="/api/recipes/tags",
prefix="/api/tags",
tags=["Recipe Tags"],
)
@router.get("/")
@router.get("")
async def get_all_recipe_tags(session: Session = Depends(generate_session)):
""" Returns a list of available tags in the database """
return db.tags.get_all_primary_keys(session)
return db.tags.get_all_limit_columns(session, ["slug", "name"])
@router.get("/{tag}")

View file

@ -1,13 +1,15 @@
from typing import List, Optional
from fastapi_camelcase import CamelModel
from mealie.schema.recipe import Recipe
class CategoryBase(CamelModel):
id: int
class CategoryIn(CamelModel):
name: str
class CategoryBase(CategoryIn):
id: int
slug: str
class Config:

View file

@ -1,3 +1,4 @@
# Make .env in this folder if needed.
DEFAULT_GROUP=Home
ENV=False
API_PORT=9000