Few UI improvements + add refetch ability

* Order by decreasing ids on the home page.
* Confirmation modals when deleting / refetching items.
* Spinners when loading.
* Getting started message when there is no recipe.
* Add the ability to refetch a recipe from the website (when a Weboob
module has been improved for instance).
This commit is contained in:
Lucas Verney 2018-03-03 15:56:18 +01:00
parent c873021500
commit b5946ccc84
13 changed files with 406 additions and 316 deletions

View File

@ -24,6 +24,11 @@ module.exports = {
}, },
// add your custom rules here // add your custom rules here
rules: { rules: {
// Use 4 spaces indent
'indent': ['error', 4],
'import/prefer-default-export': 'off',
'no-console': 'off',
'no-underscore-dangle': 'off',
// don't require .vue extension when importing // don't require .vue extension when importing
'import/extensions': ['error', 'always', { 'import/extensions': ['error', 'always', {
js: 'never', js: 'never',

View File

@ -56,21 +56,21 @@ class Recipe(Model):
class Meta: class Meta:
database = database database = database
@staticmethod def update_from_weboob(self, weboob_obj):
def from_weboob(obj): """
recipe = Recipe() Update fields taking values from the Weboob object.
"""
# Set fields # Set fields
for field in ['title', 'url', 'author', 'picture_url', for field in ['title', 'url', 'author', 'picture_url',
'short_description', 'preparation_time', 'cooking_time', 'short_description', 'preparation_time', 'cooking_time',
'ingredients', 'instructions']: 'ingredients', 'instructions']:
value = getattr(obj, field) value = getattr(weboob_obj, field)
if value: if value:
setattr(recipe, field, value) setattr(self, field, value)
# Serialize number of person # Serialize number of person
recipe.nb_person = '-'.join(str(num) for num in obj.nb_person) self.nb_person = '-'.join(str(num) for num in weboob_obj.nb_person)
# Download picture and save it as a blob # Download picture and save it as a blob
recipe.picture = requests.get(obj.picture_url).content self.picture = requests.get(weboob_obj.picture_url).content
return recipe
def to_dict(self): def to_dict(self):
""" """

View File

@ -1,31 +1,31 @@
<template> <template>
<v-app> <v-app>
<v-toolbar app> <v-toolbar app>
<v-toolbar-title class="toolbar-title"> <v-toolbar-title class="toolbar-title">
<router-link to="/">Cuizin</router-link> <router-link to="/">Cuizin</router-link>
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<router-link to="/new"> <router-link to="/new">
<v-btn icon> <v-btn icon>
<v-icon>add</v-icon> <v-icon>add</v-icon>
</v-btn> </v-btn>
</router-link> </router-link>
</v-toolbar> </v-toolbar>
<v-content> <v-content>
<router-view/> <router-view></router-view>
</v-content> </v-content>
</v-app> </v-app>
</template> </template>
<script> <script>
export default { export default {
name: 'App', name: 'App',
}; };
</script> </script>
<style> <style>
.toolbar-title a { .toolbar-title a {
color: rgba(0,0,0,.87); color: rgba(0,0,0,.87);
text-decoration: none; text-decoration: none;
} }
</style> </style>

View File

@ -1,64 +1,68 @@
<template> <template>
<v-container fluid grid-list-md> <v-container fluid grid-list-md>
<v-layout row wrap> <Loader v-if="isLoading"></Loader>
<v-flex <v-layout row wrap v-else>
v-for="recipe in recipes" <v-flex xs12 v-if="!recipes.length" class="text-xs-center">
:key="recipe.title" <p>Start by adding a recipe with the "+" button on the top right corner!</p>
xs12
sm6
md3
lg2
>
<v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}">
<v-card-media :src="recipe.picture" height="200px"></v-card-media>
<v-card-title primary-title>
<div>
<h3 class="headline mb-0">{{ recipe.title }}</h3>
</div>
</v-card-title>
<p>{{ recipe.short_description }}</p>
<v-layout row text-xs-center>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ recipe.preparation_time }} mins</p>
</v-flex> </v-flex>
<v-flex xs6> <v-flex
<p><v-icon>whatshot</v-icon> {{ recipe.cooking_time }} mins</p> v-for="recipe in recipes"
:key="recipe.title"
xs12 sm6 md3 lg2>
<v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}">
<v-card-media :src="recipe.picture" height="200px"></v-card-media>
<v-card-title primary-title>
<div>
<h3 class="headline mb-0">{{ recipe.title }}</h3>
</div>
</v-card-title>
<p>{{ recipe.short_description }}</p>
<v-layout row text-xs-center>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ recipe.preparation_time }} mins</p>
</v-flex>
<v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ recipe.cooking_time }} mins</p>
</v-flex>
</v-layout>
</v-card>
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-card> </v-container>
</v-flex>
</v-layout>
</v-container>
</template> </template>
<script> <script>
import * as constants from '@/constants'; import * as constants from '@/constants';
import Loader from '@/components/Loader';
export default { export default {
data() { components: {
return { Loader,
isLoading: false, },
recipes: [], data() {
}; return {
}, isLoading: false,
created() { recipes: [],
this.fetchRecipes(); };
}, },
watch: { created() {
// call again the method if the route changes this.fetchRecipes();
$route: 'fetchRecipes', },
}, watch: {
methods: { // call again the method if the route changes
fetchRecipes() { $route: 'fetchRecipes',
this.isLoading = true; },
methods: {
fetch(`${constants.API_URL}api/v1/recipes`) fetchRecipes() {
.then(response => response.json()) this.isLoading = true;
.then((response) => {
this.recipes = response.recipes; fetch(`${constants.API_URL}api/v1/recipes`)
this.isLoading = false; .then(response => response.json())
}); .then((response) => {
this.recipes = response.recipes;
this.isLoading = false;
});
},
}, },
},
}; };
</script> </script>

View File

@ -0,0 +1,7 @@
<template>
<v-layout row>
<v-flex xs12 class="text-xs-center">
<v-progress-circular indeterminate :size="70" :width="7"></v-progress-circular>
</v-flex>
</v-layout>
</template>

View File

@ -1,89 +1,89 @@
<template> <template>
<v-container text-xs-center> <v-container text-xs-center>
<v-layout row wrap> <v-layout row wrap>
<v-flex xs12> <v-flex xs12>
<h2>Import from URL</h2> <h2>Import from URL</h2>
<v-form v-model="validImport"> <v-form v-model="validImport">
<v-text-field <v-text-field
label="URL" label="URL"
v-model="url" v-model="url"
required required
:rules="urlRules" :rules="urlRules"
></v-text-field> ></v-text-field>
<v-btn <v-btn
@click="submitImport" @click="submitImport"
:disabled="!validImport || disabledImport" :disabled="!validImport || disabledImport"
> >
Import Import
</v-btn> </v-btn>
</v-form> </v-form>
</v-flex>
</v-layout>
<v-layout row wrap mt-5 v-if="featureAddManually">
<v-flex xs12>
<h2>Add manually</h2>
<v-form v-model="validAdd">
<v-text-field
label="Title"
v-model="title"
required
></v-text-field>
<v-text-field
label="Picture"
v-model="picture"
></v-text-field>
<v-text-field
label="Short description"
v-model="short_description"
textarea
></v-text-field>
<v-layout row>
<v-flex xs4 mr-3>
<v-text-field
label="Number of persons"
v-model="nb_person"
type="number"
></v-text-field>
</v-flex> </v-flex>
<v-flex xs4 mx-3> </v-layout>
<v-text-field <v-layout row wrap mt-5 v-if="featureAddManually">
label="Preparation time" <v-flex xs12>
v-model="preparation_time" <h2>Add manually</h2>
type="number" <v-form v-model="validAdd">
suffix="mins" <v-text-field
></v-text-field> label="Title"
</v-flex> v-model="title"
<v-flex xs4 ml-3> required
<v-text-field ></v-text-field>
label="Cooking time" <v-text-field
v-model="cooking_time" label="Picture"
type="number" v-model="picture"
suffix="mins" ></v-text-field>
></v-text-field> <v-text-field
</v-flex> label="Short description"
</v-layout> v-model="short_description"
<v-text-field textarea
label="Ingredients" ></v-text-field>
v-model="ingredients" <v-layout row>
textarea <v-flex xs4 mr-3>
></v-text-field> <v-text-field
<v-text-field label="Number of persons"
label="Instructions" v-model="nb_person"
v-model="instructions" type="number"
textarea ></v-text-field>
required </v-flex>
></v-text-field> <v-flex xs4 mx-3>
<v-text-field
label="Preparation time"
v-model="preparation_time"
type="number"
suffix="mins"
></v-text-field>
</v-flex>
<v-flex xs4 ml-3>
<v-text-field
label="Cooking time"
v-model="cooking_time"
type="number"
suffix="mins"
></v-text-field>
</v-flex>
</v-layout>
<v-text-field
label="Ingredients"
v-model="ingredients"
textarea
></v-text-field>
<v-text-field
label="Instructions"
v-model="instructions"
textarea
required
></v-text-field>
<v-btn <v-btn
@click="submitAdd" @click="submitAdd"
:disabled="!validAdd" :disabled="!validAdd"
> >
Add Add
</v-btn> </v-btn>
</v-form> </v-form>
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-container> </v-container>
</template> </template>
<script> <script>
@ -91,55 +91,55 @@ import * as constants from '@/constants';
export default { export default {
data() { data() {
return { return {
url: null, url: null,
validImport: false, validImport: false,
disabledImport: false, disabledImport: false,
validAdd: false, validAdd: false,
title: null, title: null,
picture: null, picture: null,
short_description: null, short_description: null,
nb_person: null, nb_person: null,
preparation_time: null, preparation_time: null,
cooking_time: null, cooking_time: null,
ingredients: null, ingredients: null,
instructions: null, instructions: null,
urlRules: [ urlRules: [
v => !!v || 'URL is required', v => !!v || 'URL is required',
(v) => { (v) => {
try { try {
new URL(v); // eslint-disable-line no-new new URL(v); // eslint-disable-line no-new
return true; return true;
} catch (e) { } catch (e) {
return 'URL must be valid'; return 'URL must be valid';
} }
},
],
featureAddManually: false,
};
},
methods: {
submitImport() {
this.disabledImport = true;
fetch(`${constants.API_URL}api/v1/recipes`, {
method: 'POST',
body: JSON.stringify({
url: this.url,
}),
})
.then(response => response.json())
.then(response => response.recipes[0])
.then(recipe => this.$router.push({
name: 'Recipe',
params: {
recipeId: recipe.id,
},
}));
},
submitAdd() {
// TODO
}, },
],
featureAddManually: false,
};
},
methods: {
submitImport() {
this.disabledImport = true;
fetch(`${constants.API_URL}api/v1/recipes`, {
method: 'POST',
body: JSON.stringify({
url: this.url,
}),
})
.then(response => response.json())
.then(response => response.recipes[0])
.then(recipe => this.$router.push({
name: 'Recipe',
params: {
recipeId: recipe.id,
},
}));
}, },
submitAdd() {
// TODO
},
},
}; };
</script> </script>

View File

@ -1,81 +1,133 @@
<template> <template>
<v-container grid-list-md class="panel"> <v-container grid-list-md class="panel">
<v-layout row> <Loader v-if="isLoading"></Loader>
<v-flex xs12 v-if="recipe"> <v-layout row v-else>
<h1 class="text-xs-center mt-3 mb-3"> <v-dialog v-model="refetchConfirm" max-width="500px">
{{ recipe.title }} <v-card>
</h1> <v-card-title class="headline">Refetch recipe</v-card-title>
<p> <v-card-text>
</p> This will refetch the recipe from the website and replace all current data with newly fetched ones. Are you sure?
<p class="text-xs-center"> </v-card-text>
<img :src="recipe.picture" /> <v-card-actions>
</p> <v-spacer></v-spacer>
<v-layout row class="text-xs-center"> <v-btn color="secondary" flat @click.stop="refetchConfirm=false">Cancel</v-btn>
<v-flex xs6> <v-btn color="error" flat @click.stop="handleRefetch">Refetch</v-btn>
<p>{{ recipe.nb_person }}</p> </v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteConfirm" max-width="500px">
<v-card>
<v-card-title class="headline">Delete recipe</v-card-title>
<v-card-text>This will delete this recipe. Are you sure?</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" flat @click.stop="deleteConfirm=false">Cancel</v-btn>
<v-btn color="error" flat @click.stop="handleDelete">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-flex xs12 v-if="recipe">
<h1 class="text-xs-center mt-3 mb-3">
{{ recipe.title }}
</h1>
<p>
</p>
<p class="text-xs-center">
<img :src="recipe.picture" />
</p>
<v-layout row class="text-xs-center">
<v-flex xs6>
<p>{{ recipe.nb_person }}</p>
</v-flex>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> Preparation: {{ recipe.preparation_time }}&nbsp;mins</p>
<p><v-icon>whatshot</v-icon> Cooking: {{ recipe.cooking_time }}&nbsp;mins</p>
</v-flex>
</v-layout>
<p>{{ recipe.short_description }}</p>
<h2>Ingredients</h2>
<ul class="ml-5">
<li v-for="ingredient in recipe.ingredients">
{{ ingredient }}
</li>
</ul>
<h2 class="mt-3">Instructions</h2>
<p v-for="item in recipe.instructions">
{{ item }}
</p>
<p v-if="recipe.url" class="text-xs-center">
<v-btn :href="recipe.url">
<v-icon class="fa-icon">fa-external-link</v-icon>
</v-btn>
<v-btn @click.stop="deleteConfirm = true">
<v-icon>delete</v-icon>
</v-btn>
<v-btn @click.stop="refetchConfirm = true">
<v-icon>autorenew</v-icon>
</v-btn>
</p>
</v-flex> </v-flex>
<v-flex xs6> </v-layout>
<p><v-icon>timelapse</v-icon> Preparation: {{ recipe.preparation_time }}&nbsp;mins</p> </v-container>
<p><v-icon>whatshot</v-icon> Cooking: {{ recipe.cooking_time }}&nbsp;mins</p>
</v-flex>
</v-layout>
<p>{{ recipe.short_description }}</p>
<h2>Ingredients</h2>
<ul class="ml-5">
<li v-for="ingredient in recipe.ingredients">
{{ ingredient }}
</li>
</ul>
<h2 class="mt-3">Instructions</h2>
<nl2br tag="p" :text="recipe.instructions"></nl2br>
<p v-if="recipe.url" class="text-xs-center">
<v-btn :href="recipe.url">
<v-icon class="fa-icon">fa-external-link</v-icon>
</v-btn>
<v-btn @click="handleDelete">
<v-icon>delete</v-icon>
</v-btn>
</p>
</v-flex>
</v-layout>
</v-container>
</template> </template>
<script> <script>
import * as constants from '@/constants'; import * as constants from '@/constants';
import Loader from '@/components/Loader';
export default { export default {
data() { components: {
return { Loader,
isLoading: false, },
recipe: null, data() {
}; return {
}, isLoading: false,
created() { recipe: null,
this.fetchRecipe(); deleteConfirm: false,
}, refetchConfirm: false,
watch: { };
// call again the method if the route changes },
$route: 'fetchRecipe', created() {
}, this.fetchRecipe();
methods: { },
fetchRecipe() { watch: {
this.isLoading = true; // call again the method if the route changes
$route: 'fetchRecipe',
},
methods: {
handleRecipesResponse(response) {
this.recipe = response.recipes[0];
this.recipe.instructions = this.recipe.instructions.split(/\r\n/).map(item => item.trim());
this.isLoading = false;
},
fetchRecipe() {
this.isLoading = true;
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`) fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`)
.then(response => response.json()) .then(response => response.json())
.then((response) => { .then(this.handleRecipesResponse);
this.recipe = response.recipes[0]; },
this.isLoading = false; handleDelete() {
}); this.isLoading = true;
this.deleteConfirm = false;
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`, {
method: 'DELETE',
})
.then(() => this.$router.replace('/'));
},
handleRefetch() {
this.isLoading = true;
this.refetchConfirm = false;
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}/refetch`, {
method: 'GET',
})
.then(response => response.json())
.then(this.handleRecipesResponse);
},
}, },
handleDelete() {
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`, {
method: 'DELETE',
})
.then(() => this.$router.replace('/'));
},
},
}; };
</script> </script>

View File

@ -1 +1 @@
export const API_URL = process.env.API_URL || '/'; // eslint-disable-line import/prefer-default-export,no-use-before-define export const API_URL = process.env.API_URL || '/'; // eslint-disable-line no-use-before-define

View File

@ -6,7 +6,6 @@ import 'roboto-fontface/css/roboto/roboto-fontface.css';
import 'font-awesome/css/font-awesome.css'; import 'font-awesome/css/font-awesome.css';
import 'material-design-icons/iconfont/material-icons.css'; import 'material-design-icons/iconfont/material-icons.css';
import 'vuetify/dist/vuetify.min.css'; import 'vuetify/dist/vuetify.min.css';
import Nl2br from 'vue-nl2br';
import App from './App'; import App from './App';
import router from './router'; import router from './router';
@ -15,15 +14,14 @@ import router from './router';
require('es6-promise').polyfill(); require('es6-promise').polyfill();
require('isomorphic-fetch'); require('isomorphic-fetch');
Vue.component('nl2br', Nl2br);
Vue.use(Vuetify); Vue.use(Vuetify);
Vue.config.productionTip = false; Vue.config.productionTip = false;
/* eslint-disable no-new */ /* eslint-disable no-new */
new Vue({ new Vue({
el: '#app', el: '#app',
router, router,
components: { App }, components: { App },
template: '<App/>', template: '<App/>',
}); });

View File

@ -7,21 +7,21 @@ import Recipe from '@/components/Recipe';
Vue.use(Router); Vue.use(Router);
export default new Router({ export default new Router({
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'Home', name: 'Home',
component: Home, component: Home,
}, },
{ {
path: '/new', path: '/new',
name: 'New', name: 'New',
component: New, component: New,
}, },
{ {
path: '/recipe/:recipeId', path: '/recipe/:recipeId',
name: 'Recipe', name: 'Recipe',
component: Recipe, component: Recipe,
}, },
], ],
}); });

View File

@ -15,11 +15,12 @@ from cuizin import db
BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette'] BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette']
def add_recipe(url): def fetch_recipe(url, recipe=None):
""" """
Add a recipe, trying to scrape from a given URL. Add a recipe, trying to scrape from a given URL.
:param url: URL of the recipe. :param url: URL of the recipe.
:param recipe: An optional recipe object to update.
:return: A ``cuizin.db.Recipe`` model. :return: A ``cuizin.db.Recipe`` model.
""" """
# Eventually load modules from a local clone # Eventually load modules from a local clone
@ -36,22 +37,20 @@ def add_recipe(url):
for module in BACKENDS for module in BACKENDS
] ]
# Create a new Recipe object if none is given
if not recipe:
recipe = db.Recipe()
# Try to fetch the recipe with a Weboob backend # Try to fetch the recipe with a Weboob backend
recipe = None
for backend in backends: for backend in backends:
browser = backend.browser browser = backend.browser
if url.startswith(browser.BASEURL): if url.startswith(browser.BASEURL):
browser.location(url) browser.location(url)
recipe = db.Recipe.from_weboob(browser.page.get_recipe()) recipe.update_from_weboob(browser.page.get_recipe())
# Ensure URL is set
recipe.url = url
break break
# If we could not scrape anything, simply create an empty recipe storing # Ensure URL is set
# the URL. recipe.url = url
if not recipe:
recipe = db.Recipe()
recipe.url = url
recipe.save() recipe.save()
return recipe return recipe

View File

@ -4,7 +4,7 @@ import os
import bottle import bottle
from cuizin import db from cuizin import db
from cuizin.scraping import add_recipe from cuizin.scraping import fetch_recipe
MODULE_DIR = os.path.dirname(os.path.realpath(__file__)) MODULE_DIR = os.path.dirname(os.path.realpath(__file__))
@ -49,7 +49,8 @@ def api_v1_recipes():
return { return {
'recipes': [ 'recipes': [
recipe.to_dict() for recipe in db.Recipe.select() recipe.to_dict()
for recipe in db.Recipe.select().order_by(db.Recipe.id.desc())
] ]
} }
@ -73,7 +74,7 @@ def api_v1_recipes_post():
assert recipe assert recipe
recipes = [recipe.to_dict()] recipes = [recipe.to_dict()]
except AssertionError: except AssertionError:
recipes = [add_recipe(data['url']).to_dict()] recipes = [fetch_recipe(data['url']).to_dict()]
return { return {
'recipes': recipes 'recipes': recipes
@ -98,6 +99,31 @@ def api_v1_recipe(id):
} }
@app.route('/api/v1/recipe/:id/refetch', ['GET', 'OPTIONS'])
def api_v1_recipe_refetch(id):
"""
Refetch a given recipe.
"""
# CORS
if bottle.request.method == 'OPTIONS':
return ''
recipe = db.Recipe.select().where(
db.Recipe.id == id
).first()
if not recipe:
# TODO: Error
pass
recipe = fetch_recipe(recipe.url, recipe=recipe)
return {
'recipes': [
recipe.to_dict()
]
}
@app.delete('/api/v1/recipe/:id', ['DELETE', 'OPTIONS']) @app.delete('/api/v1/recipe/:id', ['DELETE', 'OPTIONS'])
def api_v1_recipe_delete(id): def api_v1_recipe_delete(id):
""" """

View File

@ -17,7 +17,6 @@
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"roboto-fontface": "^0.9.0", "roboto-fontface": "^0.9.0",
"vue": "^2.5.2", "vue": "^2.5.2",
"vue-nl2br": "^0.0.5",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vuetify": "^1.0.0" "vuetify": "^1.0.0"
}, },