Compare commits
22 Commits
Author | SHA1 | Date |
---|---|---|
Lucas Verney | 73ae6d89f8 | |
Lucas Verney | 171bf33fff | |
Lucas Verney | 680f00c188 | |
Lucas Verney | 9bbac3b99e | |
Lucas Verney | 9813796a5d | |
Lucas Verney | 8a5450c40c | |
Lucas Verney | 6fad7bec13 | |
Lucas Verney | 40fbc5b662 | |
Lucas Verney | f529619c99 | |
Lucas Verney | d30e5f9053 | |
Lucas Verney | da844e0a08 | |
Lucas Verney | 55751467f8 | |
Lucas Verney | f60b59d789 | |
Lucas Verney | e128171ddc | |
Lucas Verney | 09e0c4f883 | |
Lucas Verney | 1c4053de8e | |
Lucas Verney | f042214c92 | |
Lucas Verney | b5946ccc84 | |
Lucas Verney | c873021500 | |
Lucas Verney | a1bf22c31e | |
Lucas Verney | 3ace510aac | |
Lucas Verney | 9aa4756887 |
|
@ -24,6 +24,11 @@ module.exports = {
|
|||
},
|
||||
// add your custom rules here
|
||||
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
|
||||
'import/extensions': ['error', 'always', {
|
||||
js: 'never',
|
||||
|
|
18
README.md
18
README.md
|
@ -5,12 +5,18 @@ Cuizin is a tool wrapping around [Web Outside Of Browsers](http://weboob.org/)
|
|||
to help you sort and organize recipes you find online. You can also manually
|
||||
add new recipes.
|
||||
|
||||
![Homepage](screenshots/home.png)
|
||||
|
||||
More screenshots are available in the [`screenshots` folder](screenshots/).
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Cuizin requires Python 3 and may not be compatible with Python 2.
|
||||
|
||||
```
|
||||
$ git clone … # Clone this repository
|
||||
$ pip install -r requirements.txt # Install Python dependencies
|
||||
$ pip3 install -r requirements.txt # Install Python dependencies
|
||||
$ npm install # Install JS dependencies for the frontend
|
||||
$ npm run build # Build the JS dependencies
|
||||
```
|
||||
|
@ -31,7 +37,7 @@ WSGI to serve the application directly (`application` variable is exported in
|
|||
|
||||
You can customize the behavior of the app by passing environment variables:
|
||||
* `CUIZIN_HOST` to set the host on which the webserver should listen to
|
||||
(defaults to `localhost` only). Use `HOST=0.0.0.0` to make it
|
||||
(defaults to `localhost` only). Use `CUIZIN_HOST=0.0.0.0` to make it
|
||||
world-accessible.
|
||||
* `CUIZIN_PORT` to set the port on which the webserver should listen. Defaults
|
||||
to `8080`.
|
||||
|
@ -47,6 +53,14 @@ If you serve the app with a reverse proxy, you should serve the content of
|
|||
Bottle webserver.
|
||||
|
||||
|
||||
## Updating
|
||||
|
||||
If you are updating the app, the database schema may have changed. There is a
|
||||
set of migrations under the `migrations` folder which is a set of standalone
|
||||
scripts to run to update your database schema. Each script is named after the
|
||||
commit it is handling.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
All contributions are welcome, feel free to open a MR. Just in case, if you
|
||||
|
|
57
cuizin/db.py
57
cuizin/db.py
|
@ -40,7 +40,7 @@ class Recipe(Model):
|
|||
# Author
|
||||
author = CharField(null=True)
|
||||
# Picture as a binary blob
|
||||
picture = BlobField(null=True)
|
||||
picture = TextField(null=True)
|
||||
# Short description
|
||||
short_description = TextField(null=True)
|
||||
# Number of persons as text, as it can be either "N persons" or "N parts"
|
||||
|
@ -56,36 +56,45 @@ class Recipe(Model):
|
|||
class Meta:
|
||||
database = database
|
||||
|
||||
@staticmethod
|
||||
def from_weboob(obj):
|
||||
recipe = Recipe()
|
||||
def update_from_dict(self, d):
|
||||
"""
|
||||
Update field taking values from a dict of values.
|
||||
"""
|
||||
# Set fields
|
||||
for field in ['title', 'url', 'author', 'picture_url',
|
||||
'short_description', 'preparation_time', 'cooking_time',
|
||||
'ingredients', 'instructions']:
|
||||
value = getattr(obj, field)
|
||||
'ingredients', 'instructions', 'nb_person']:
|
||||
value = d.get(field, None)
|
||||
if value:
|
||||
setattr(recipe, field, value)
|
||||
# Serialize number of person
|
||||
recipe.nb_person = '-'.join(str(num) for num in obj.nb_person)
|
||||
setattr(self, field, value)
|
||||
# Download picture and save it as a blob
|
||||
recipe.picture = requests.get(obj.picture_url).content
|
||||
return recipe
|
||||
if d.get('picture_url', None):
|
||||
try:
|
||||
picture = requests.get(d['picture_url']).content
|
||||
picture_mime = (
|
||||
'data:%s;base64' % magic.from_buffer(picture,
|
||||
mime=True)
|
||||
)
|
||||
self.picture = '%s,%s' % (
|
||||
picture_mime,
|
||||
base64.b64encode(picture).decode('utf-8')
|
||||
)
|
||||
except requests.exceptions.InvalidSchema:
|
||||
self.picture = d['picture_url']
|
||||
|
||||
def update_from_weboob(self, weboob_obj):
|
||||
"""
|
||||
Update fields taking values from the Weboob object.
|
||||
"""
|
||||
weboob_dict = dict(weboob_obj.iter_fields())
|
||||
if weboob_dict.get('nb_person', None):
|
||||
weboob_dict['nb_person'] = '-'.join(
|
||||
str(num) for num in weboob_dict['nb_person']
|
||||
)
|
||||
self.update_from_dict(weboob_dict)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Dict conversion function, for serialization in the API.
|
||||
"""
|
||||
serialized = model_to_dict(self)
|
||||
# Dump picture as a base64 string, compatible with HTML `src` attribute
|
||||
# for images.
|
||||
if serialized['picture']:
|
||||
picture_mime = (
|
||||
'data:%s;base64' % magic.from_buffer(serialized['picture'],
|
||||
mime=True)
|
||||
)
|
||||
serialized['picture'] = '%s,%s' % (
|
||||
picture_mime,
|
||||
base64.b64encode(serialized['picture']).decode('utf-8')
|
||||
)
|
||||
return serialized
|
||||
return model_to_dict(self)
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-toolbar app>
|
||||
<v-toolbar-title class="toolbar-title">
|
||||
<router-link to="/">Cuizin</router-link>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<router-link to="/new">
|
||||
<v-btn icon>
|
||||
<v-icon>add</v-icon>
|
||||
</v-btn>
|
||||
</router-link>
|
||||
</v-toolbar>
|
||||
<v-content>
|
||||
<router-view/>
|
||||
</v-content>
|
||||
</v-app>
|
||||
<v-app>
|
||||
<v-toolbar app>
|
||||
<v-toolbar-title class="toolbar-title">
|
||||
<router-link to="/">Cuizin</router-link>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<router-link to="/new">
|
||||
<v-btn icon>
|
||||
<v-icon>add</v-icon>
|
||||
</v-btn>
|
||||
</router-link>
|
||||
</v-toolbar>
|
||||
<v-content>
|
||||
<router-view></router-view>
|
||||
</v-content>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App',
|
||||
name: 'App',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toolbar-title a {
|
||||
color: rgba(0,0,0,.87);
|
||||
text-decoration: none;
|
||||
color: rgba(0,0,0,.87);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
import * as constants from '@/constants';
|
||||
|
||||
|
||||
function fetchAPI(endpoint, params = {}) {
|
||||
return fetch(
|
||||
`${constants.API_URL}${endpoint}`,
|
||||
Object.assign({}, { credentials: 'same-origin' }, params),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function loadJSON(response) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
function _postProcessRecipes(response) {
|
||||
return loadJSON(response)
|
||||
.then((jsonResponse) => {
|
||||
const parsed = jsonResponse;
|
||||
|
||||
if (parsed.recipes) {
|
||||
// Customize instructions
|
||||
parsed.recipes = parsed.recipes.map(item => Object.assign(
|
||||
item,
|
||||
{
|
||||
instructions: item.instructions ? item.instructions.split(/[\r\n]\n/).map(
|
||||
line => line.trim(),
|
||||
) : [],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return parsed;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function loadRecipes() {
|
||||
return fetchAPI('api/v1/recipes')
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function loadRecipe(id) {
|
||||
return fetchAPI(`api/v1/recipe/${id}`)
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function refetchRecipe(id) {
|
||||
return fetchAPI(`api/v1/recipe/${id}/refetch`)
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function postRecipeByUrl(recipe) {
|
||||
return fetchAPI('api/v1/recipes/by_url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(recipe),
|
||||
})
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function postRecipeManually(recipe) {
|
||||
return fetchAPI('api/v1/recipes/manually', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(recipe),
|
||||
})
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function editRecipe(id, recipe) {
|
||||
return fetchAPI(`api/v1/recipe/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(recipe),
|
||||
})
|
||||
.then(_postProcessRecipes);
|
||||
}
|
||||
|
||||
|
||||
export function deleteRecipe(id) {
|
||||
return fetchAPI(`api/v1/recipe/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
|
@ -0,0 +1,224 @@
|
|||
<template>
|
||||
<v-flex xs12>
|
||||
<ErrorDialog v-model="error" :description="$t('error.title')" />
|
||||
|
||||
<h2 v-if="recipe">{{ $t('new.edit_recipe') }}</h2>
|
||||
<h2 v-else>{{ $t('new.add_manually') }}</h2>
|
||||
|
||||
<v-form v-model="isValidForm">
|
||||
<v-text-field
|
||||
:label="$t('new.title')"
|
||||
v-model="title"
|
||||
required
|
||||
:rules="[v => !!v || $t('new.title_is_required')]"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
:label="$t('new.picture_url')"
|
||||
v-model="picture_url"
|
||||
:rules="urlRules"
|
||||
></v-text-field>
|
||||
<p>
|
||||
<img :src="picture_url" />
|
||||
</p>
|
||||
<v-text-field
|
||||
:label="$t('new.short_description')"
|
||||
v-model="short_description"
|
||||
textarea
|
||||
></v-text-field>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs12 md5>
|
||||
<v-text-field
|
||||
:label="$t('new.preparation_time')"
|
||||
v-model="preparation_time"
|
||||
type="number"
|
||||
:suffix="$t('new.mins')"
|
||||
></v-text-field>
|
||||
</v-flex>
|
||||
<v-flex xs12 md5 offset-md2>
|
||||
<v-text-field
|
||||
:label="$t('new.cooking_time')"
|
||||
v-model="cooking_time"
|
||||
type="number"
|
||||
:suffix="$t('new.mins')"
|
||||
></v-text-field>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-text-field
|
||||
:label="$t('new.nb_persons')"
|
||||
v-model="nb_person"
|
||||
></v-text-field>
|
||||
<v-layout row>
|
||||
<v-flex xs12 class="text-xs-left">
|
||||
<h3>{{ $t('new.ingredients') }}</h3>
|
||||
<v-list v-if="ingredients.length" class="transparent">
|
||||
<v-list-tile v-for="ingredient in ingredients" :key="ingredient">
|
||||
<v-list-tile-action>
|
||||
<v-btn flat icon color="red" v-on:click="() => removeIngredient(ingredient)">
|
||||
<v-icon>delete</v-icon>
|
||||
</v-btn>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>{{ ingredient }}</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list>
|
||||
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
|
||||
<v-text-field
|
||||
:label="$t('new.add_ingredient')"
|
||||
v-model="new_ingredient"
|
||||
@keyup.enter.native="addIngredient"
|
||||
></v-text-field>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-text-field
|
||||
:label="$t('new.instructions')"
|
||||
v-model="instructions"
|
||||
:rules="[v => !!v || $t('new.instructions_are_required')]"
|
||||
textarea
|
||||
required
|
||||
></v-text-field>
|
||||
|
||||
<v-btn
|
||||
@click="submitEdit"
|
||||
:disabled="!isValidForm || isImporting"
|
||||
v-if="recipe"
|
||||
>
|
||||
{{ $t('new.edit') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="submitAdd"
|
||||
:disabled="!isValidForm || isImporting"
|
||||
v-else
|
||||
>
|
||||
{{ $t('new.add') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-flex>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as api from '@/api';
|
||||
import * as rules from '@/rules';
|
||||
|
||||
import ErrorDialog from '@/components/ErrorDialog';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorDialog,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
default: null,
|
||||
type: Object,
|
||||
},
|
||||
value: Boolean,
|
||||
},
|
||||
computed: {
|
||||
isImporting: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
let defaultPreparationTime = null;
|
||||
if (this.recipe &&
|
||||
this.recipe.preparation_time !== null && this.recipe.preparation_time !== undefined
|
||||
) {
|
||||
defaultPreparationTime = this.recipe.preparation_time;
|
||||
}
|
||||
let defaultCookingTime = null;
|
||||
if (this.recipe &&
|
||||
this.recipe.cooking_time !== null && this.recipe.cooking_time !== undefined
|
||||
) {
|
||||
defaultCookingTime = this.recipe.cooking_time;
|
||||
}
|
||||
return {
|
||||
error: null,
|
||||
url: null,
|
||||
isValidForm: false,
|
||||
title: (this.recipe && this.recipe.title) || null,
|
||||
picture_url: (this.recipe && this.recipe.picture) || null,
|
||||
short_description: (this.recipe && this.recipe.short_description) || null,
|
||||
nb_person: (this.recipe && this.recipe.nb_person) || null,
|
||||
preparation_time: defaultPreparationTime,
|
||||
cooking_time: defaultCookingTime,
|
||||
new_ingredient: null,
|
||||
ingredients: (this.recipe && this.recipe.ingredients) || [],
|
||||
instructions: (this.recipe && this.recipe.instructions.join('\n\n').replace(/\n{2,}/, '\n\n')) || null,
|
||||
urlRules: rules.url,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addIngredient() {
|
||||
this.ingredients.push(this.new_ingredient);
|
||||
this.new_ingredient = null;
|
||||
},
|
||||
removeIngredient(ingredient) {
|
||||
const index = this.ingredients.indexOf(ingredient);
|
||||
if (index !== -1) {
|
||||
this.ingredients.splice(index, 1);
|
||||
}
|
||||
},
|
||||
submitAdd() {
|
||||
this.isImporting = true;
|
||||
api.postRecipeManually({
|
||||
title: this.title,
|
||||
picture_url: this.picture_url,
|
||||
short_description: this.short_description,
|
||||
preparation_time: this.preparation_time,
|
||||
cooking_time: this.cooking_time,
|
||||
nb_person: this.nb_person,
|
||||
ingredients: this.ingredients,
|
||||
instructions: this.instructions,
|
||||
})
|
||||
.then(response => this.$router.push({
|
||||
name: 'Recipe',
|
||||
params: {
|
||||
recipeId: response.recipes[0].id,
|
||||
},
|
||||
}))
|
||||
.catch((error) => {
|
||||
this.isImporting = false;
|
||||
this.error = error;
|
||||
});
|
||||
},
|
||||
submitEdit() {
|
||||
this.isImporting = true;
|
||||
api.editRecipe(this.recipe.id, {
|
||||
title: this.title,
|
||||
picture_url: this.picture_url,
|
||||
short_description: this.short_description,
|
||||
preparation_time: this.preparation_time,
|
||||
cooking_time: this.cooking_time,
|
||||
nb_person: this.nb_person,
|
||||
ingredients: this.ingredients,
|
||||
instructions: this.instructions,
|
||||
})
|
||||
.then(response => this.$router.push({
|
||||
name: 'Recipe',
|
||||
params: {
|
||||
recipeId: response.recipes[0].id,
|
||||
},
|
||||
}))
|
||||
.catch((error) => {
|
||||
this.isImporting = false;
|
||||
this.error = error;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transparent {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 150px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<v-dialog v-if="error" v-model="error" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ $t('error.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
{{ description }} {{ value.message }}.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="secondary" flat @click.stop="error=null">{{ $t('misc.cancel') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
description: String,
|
||||
value: Error,
|
||||
},
|
||||
computed: {
|
||||
error: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,61 +0,0 @@
|
|||
<template>
|
||||
<v-container fluid grid-list-md>
|
||||
<v-layout row wrap>
|
||||
<v-flex
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.title"
|
||||
xs3
|
||||
>
|
||||
<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-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as constants from '@/constants';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
recipes: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchRecipes();
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
$route: 'fetchRecipes',
|
||||
},
|
||||
methods: {
|
||||
fetchRecipes() {
|
||||
this.isLoading = true;
|
||||
|
||||
fetch(`${constants.API_URL}api/v1/recipes`)
|
||||
.then(response => response.json())
|
||||
.then((response) => {
|
||||
this.recipes = response.recipes;
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<v-flex xs12>
|
||||
<ErrorDialog v-model="error" :description="$t('error.unable_import_recipe')" />
|
||||
|
||||
<h2>{{ $t('new.import_from_url') }}</h2>
|
||||
<v-form v-model="isValidImport">
|
||||
<v-text-field
|
||||
label="URL"
|
||||
v-model="url"
|
||||
required
|
||||
:rules="requiredUrlRules"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
@click="submitImport"
|
||||
:disabled="!isValidImport || isImporting"
|
||||
>
|
||||
{{ $t('new.import') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-flex>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as api from '@/api';
|
||||
import * as rules from '@/rules';
|
||||
|
||||
import ErrorDialog from '@/components/ErrorDialog';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
computed: {
|
||||
isImporting: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ErrorDialog,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
error: null,
|
||||
url: null,
|
||||
isValidImport: false,
|
||||
requiredUrlRules: Array.concat(
|
||||
[],
|
||||
[v => !!v || this.$t('new.url_is_required')],
|
||||
rules.url,
|
||||
),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submitImport() {
|
||||
this.isImporting = true;
|
||||
api.postRecipeByUrl({ url: this.url })
|
||||
.then(response => this.$router.push({
|
||||
name: 'Recipe',
|
||||
params: {
|
||||
recipeId: response.recipes[0].id,
|
||||
},
|
||||
}))
|
||||
.catch((error) => {
|
||||
this.isImporting = false;
|
||||
this.error = error;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -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>
|
|
@ -1,145 +0,0 @@
|
|||
<template>
|
||||
<v-container text-xs-center>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs12>
|
||||
<h2>Import from URL</h2>
|
||||
<v-form v-model="validImport">
|
||||
<v-text-field
|
||||
label="URL"
|
||||
v-model="url"
|
||||
required
|
||||
:rules="urlRules"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
@click="submitImport"
|
||||
:disabled="!validImport || disabledImport"
|
||||
>
|
||||
Import
|
||||
</v-btn>
|
||||
</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 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
|
||||
@click="submitAdd"
|
||||
:disabled="!validAdd"
|
||||
>
|
||||
Add
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as constants from '@/constants';
|
||||
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
url: null,
|
||||
validImport: false,
|
||||
disabledImport: false,
|
||||
validAdd: false,
|
||||
title: null,
|
||||
picture: null,
|
||||
short_description: null,
|
||||
nb_person: null,
|
||||
preparation_time: null,
|
||||
cooking_time: null,
|
||||
ingredients: null,
|
||||
instructions: null,
|
||||
urlRules: [
|
||||
v => !!v || 'URL is required',
|
||||
(v) => {
|
||||
try {
|
||||
new URL(v); // eslint-disable-line no-new
|
||||
return true;
|
||||
} catch (e) {
|
||||
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
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,78 +0,0 @@
|
|||
<template>
|
||||
<v-container grid-list-md>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs6 offset-xs3>
|
||||
<h1 class="text-xs-center mt-3 mb-3">
|
||||
{{ this.recipe.title }}
|
||||
<v-btn @click="handleDelete">Delete</v-btn>
|
||||
</h1>
|
||||
<p class="text-xs-center">
|
||||
<img :src="this.recipe.picture" />
|
||||
</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>
|
||||
<ul>
|
||||
<li v-for="ingredient in this.recipe.ingredients">
|
||||
{{ ingredient }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>{{ this.recipe.nb_person }}</p>
|
||||
<p>{{ this.recipe.short_description }}</p>
|
||||
<p>{{ this.recipe.instructions }}</p>
|
||||
<p v-if="this.recipe.url">
|
||||
<a :href="this.recipe.url">Original link</a>
|
||||
</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as constants from '@/constants';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
recipe: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchRecipe();
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
$route: 'fetchRecipe',
|
||||
},
|
||||
methods: {
|
||||
fetchRecipe() {
|
||||
this.isLoading = true;
|
||||
|
||||
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`)
|
||||
.then(response => response.json())
|
||||
.then((response) => {
|
||||
this.recipe = response.recipes[0];
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
handleDelete() {
|
||||
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => this.$router.replace('/'));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 75%;
|
||||
}
|
||||
</style>
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Get all locales supported by the client browser.
|
||||
*/
|
||||
export function getBrowserLocales() {
|
||||
let langs = [];
|
||||
|
||||
if (navigator.languages) {
|
||||
// Chrome does not currently set navigator.language correctly
|
||||
// https://code.google.com/p/chromium/issues/detail?id=101138
|
||||
// but it does set the first element of navigator.languages correctly
|
||||
langs = navigator.languages;
|
||||
} else if (navigator.userLanguage) {
|
||||
// IE only
|
||||
langs = [navigator.userLanguage];
|
||||
} else {
|
||||
// as of this writing the latest version of firefox + safari set this correctly
|
||||
langs = [navigator.language];
|
||||
}
|
||||
|
||||
// Some browsers does not return uppercase for second part
|
||||
const locales = langs.map((lang) => {
|
||||
const locale = lang.split('-');
|
||||
return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang;
|
||||
});
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find the best matching locale from the browser preferred locales.
|
||||
*
|
||||
* @param messages A translation object with supported locales as keys.
|
||||
* @param defaultLocale An optional default locale.
|
||||
* @return The best locale to use.
|
||||
*/
|
||||
export function getBestMatchingLocale(messages, defaultLocale = 'en') {
|
||||
const locales = getBrowserLocales();
|
||||
|
||||
let bestLocale = defaultLocale;
|
||||
// Get best matching locale
|
||||
locales.some((locale) => {
|
||||
if (messages[locale]) {
|
||||
bestLocale = locale;
|
||||
// Stop at first matching locale
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return bestLocale;
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
export default {
|
||||
error: {
|
||||
title: 'Error',
|
||||
unable_delete_recipe: 'Unable to delete recipe: ',
|
||||
unable_fetch_recipe: 'Unable to fetch recipe: ',
|
||||
unable_import_recipe: 'Unable to import recipe: ',
|
||||
unable_load_recipes: 'Unable to load recipes: ',
|
||||
unable_refetch_recipe: 'Unable to refetch recipe: ',
|
||||
},
|
||||
home: {
|
||||
onboarding: 'Start by adding a recipe with the "+" button on the top right corner!',
|
||||
},
|
||||
misc: {
|
||||
cancel: 'Cancel',
|
||||
Nmins: '1 min | {count} mins',
|
||||
},
|
||||
new: {
|
||||
add: 'Add',
|
||||
add_ingredient: 'Add ingredient',
|
||||
add_manually: 'Add manually',
|
||||
cooking_time: 'Cooking time',
|
||||
edit: 'Edit',
|
||||
edit_recipe: 'Edit recipe',
|
||||
import: 'Import',
|
||||
import_from_url: 'Import from URL',
|
||||
importing: 'Importing…',
|
||||
ingredients: 'Ingredients:',
|
||||
instructions: 'Instructions',
|
||||
instructions_are_required: 'Instructions are required',
|
||||
mins: 'mins',
|
||||
nb_persons: 'Serves',
|
||||
none: 'None',
|
||||
picture_url: 'Picture URL',
|
||||
preparation_time: 'Preparation time',
|
||||
short_description: 'Short description',
|
||||
title: 'Title',
|
||||
title_is_required: 'Title is required',
|
||||
updating: 'Updating…',
|
||||
url_is_required: 'URL is required',
|
||||
url_must_be_valid: 'URL must be a valid one',
|
||||
},
|
||||
recipe: {
|
||||
cooking: 'Cooking:',
|
||||
delete: 'Delete',
|
||||
delete_recipe: 'Delete recipe',
|
||||
delete_recipe_description: 'This will delete this recipe. Are you sure?',
|
||||
edit: 'Edit',
|
||||
ingredients: 'Ingredients',
|
||||
instructions: 'Instructions',
|
||||
none: 'Aucun',
|
||||
preparation: 'Preparation:',
|
||||
refetch: 'Refetch',
|
||||
refetch_recipe: 'Refetch recipe',
|
||||
refetch_recipe_description: 'This will refetch the recipe from the website and replace all current data with newly fetched ones. Are you sure?',
|
||||
website: 'Recipe web page',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,57 @@
|
|||
export default {
|
||||
error: {
|
||||
title: 'Erreur',
|
||||
unable_delete_recipe: 'Impossible de supprimer la recette : ',
|
||||
unable_fetch_recipe: 'Impossible de récupérer la recette : ',
|
||||
unable_import_recipe: 'Impossible d\'importer la recette : ',
|
||||
unable_load_recipes: 'Impossible de charger les recettes : ',
|
||||
unable_refetch_recipe: 'Impossible de réactualiser la recette : ',
|
||||
},
|
||||
home: {
|
||||
onboarding: 'Commencez par ajouter une recette avec le bouton "+" dans le coin supérieur droit !',
|
||||
},
|
||||
misc: {
|
||||
cancel: 'Annuler',
|
||||
Nmins: '1 min | {count} mins',
|
||||
},
|
||||
new: {
|
||||
add: 'Ajouter',
|
||||
add_ingredient: 'Ajouter un ingrédient',
|
||||
add_manually: 'Ajouter manuellement',
|
||||
cooking_time: 'Temps de cuisson',
|
||||
edit: 'Modifier',
|
||||
edit_recipe: 'Modifier une recette',
|
||||
import: 'Importer',
|
||||
import_from_url: 'Importer depuis l\'URL',
|
||||
importing: 'En cours d\'import…',
|
||||
ingredients: 'Ingrédients :',
|
||||
instructions: 'Instructions',
|
||||
instructions_are_required: 'Des instructions sont requises',
|
||||
mins: 'mins',
|
||||
nb_persons: 'Nombre de personnes / parts',
|
||||
none: 'Aucun',
|
||||
picture_url: 'URL d\'une photo',
|
||||
preparation_time: 'Temps de préparation',
|
||||
short_description: 'Description brève',
|
||||
title: 'Titre',
|
||||
title_is_required: 'Un titre est requis',
|
||||
updating: 'En cours de mise à jour…',
|
||||
url_is_required: 'Une URL est requise',
|
||||
url_must_be_valid: 'L\'URL est invalide',
|
||||
},
|
||||
recipe: {
|
||||
cooking: 'Cuisson :',
|
||||
delete: 'Supprimer',
|
||||
delete_recipe: 'Suppression d\'une recette',
|
||||
delete_recipe_description: 'Vous allez supprimer cette recette. En êtes-vous sûr ?',
|
||||
edit: 'Modifier',
|
||||
ingredients: 'Ingrédients',
|
||||
instructions: 'Instructions',
|
||||
none: 'Aucun',
|
||||
preparation: 'Préparation :',
|
||||
refetch: 'Réactualiser',
|
||||
refetch_recipe: 'Réactualiser la recette',
|
||||
refetch_recipe_description: 'Vous allez remplacer le contenu de cette recette par des données à jour récupérées depuis le site web de la recette. En êtes-vous sûr ?',
|
||||
website: 'Page de la recette',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import en from './en';
|
||||
import fr from './fr';
|
||||
|
||||
export default {
|
||||
en,
|
||||
fr,
|
||||
};
|
|
@ -2,23 +2,36 @@
|
|||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||
import Vue from 'vue';
|
||||
import Vuetify from 'vuetify';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import 'roboto-fontface/css/roboto/roboto-fontface.css';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
import 'material-design-icons/iconfont/material-icons.css';
|
||||
import 'vuetify/dist/vuetify.min.css';
|
||||
|
||||
import App from './App';
|
||||
import messages from './i18n';
|
||||
import router from './router';
|
||||
import { getBestMatchingLocale } from './helpers';
|
||||
|
||||
// Isomorphic fetch
|
||||
require('es6-promise').polyfill();
|
||||
require('isomorphic-fetch');
|
||||
|
||||
Vue.use(Vuetify);
|
||||
Vue.use(VueI18n);
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
const i18n = new VueI18n({
|
||||
locale: getBestMatchingLocale(messages),
|
||||
messages,
|
||||
});
|
||||
|
||||
/* eslint-disable no-new */
|
||||
new Vue({
|
||||
el: '#app',
|
||||
router,
|
||||
components: { App },
|
||||
template: '<App/>',
|
||||
el: '#app',
|
||||
i18n,
|
||||
router,
|
||||
components: { App },
|
||||
template: '<App/>',
|
||||
});
|
||||
|
|
|
@ -1,27 +1,34 @@
|
|||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
import Home from '@/components/Home';
|
||||
import New from '@/components/New';
|
||||
import Recipe from '@/components/Recipe';
|
||||
|
||||
import Home from '@/views/Home';
|
||||
import Edit from '@/views/Edit';
|
||||
import New from '@/views/New';
|
||||
import Recipe from '@/views/Recipe';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/new',
|
||||
name: 'New',
|
||||
component: New,
|
||||
},
|
||||
{
|
||||
path: '/recipe/:recipeId',
|
||||
name: 'Recipe',
|
||||
component: Recipe,
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/new',
|
||||
name: 'New',
|
||||
component: New,
|
||||
},
|
||||
{
|
||||
path: '/recipe/:recipeId',
|
||||
name: 'Recipe',
|
||||
component: Recipe,
|
||||
},
|
||||
{
|
||||
path: '/edit/:recipeId',
|
||||
name: 'Edit',
|
||||
component: Edit,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
export const url = [
|
||||
(v) => {
|
||||
if (!v) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new URL(v); // eslint-disable-line no-new
|
||||
return true;
|
||||
} catch (e) {
|
||||
return this.$t('new.url_must_be_valid');
|
||||
}
|
||||
},
|
||||
];
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<v-container text-xs-center v-if="isLoading">
|
||||
<v-layout row>
|
||||
<Loader></Loader>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
<v-container text-xs-center v-else-if="isImporting">
|
||||
<v-layout row wrap>
|
||||
<Loader></Loader>
|
||||
|
||||
<v-flex xs12>
|
||||
<p>{{ $t('new.updating') }}</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
<v-container text-xs-center v-else class="panel">
|
||||
<v-layout row wrap>
|
||||
<EditForm v-model="isImporting" :recipe="recipe"></EditForm>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as api from '@/api';
|
||||
|
||||
import EditForm from '@/components/EditForm';
|
||||
import Loader from '@/components/Loader';
|
||||
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditForm,
|
||||
Loader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isImporting: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
recipe: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.loadRecipe();
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
$route: 'loadRecipe',
|
||||
},
|
||||
methods: {
|
||||
handleRecipesResponse(response) {
|
||||
if (response.recipes.length < 1) {
|
||||
this.$router.replace({
|
||||
name: 'Home',
|
||||
});
|
||||
}
|
||||
this.recipe = response.recipes[0];
|
||||
this.isLoading = false;
|
||||
},
|
||||
loadRecipe() {
|
||||
this.isLoading = true;
|
||||
|
||||
api.loadRecipe(this.$route.params.recipeId)
|
||||
.then(this.handleRecipesResponse)
|
||||
.catch((error) => {
|
||||
this.isLoading = false;
|
||||
this.error = error;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<v-container fluid grid-list-md>
|
||||
<Loader v-if="isLoading"></Loader>
|
||||
<v-layout row wrap v-else>
|
||||
<ErrorDialog v-model="error" :description="$t('error.unable_load_recipes')" />
|
||||
|
||||
<v-flex xs12 v-if="!error && !recipes.length" class="text-xs-center">
|
||||
<p>{{ $t('home.onboarding') }}</p>
|
||||
</v-flex>
|
||||
<v-flex
|
||||
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" class="grey"></v-card-media>
|
||||
<v-card-title primary-title>
|
||||
<div>
|
||||
<h3 class="headline mb-0">{{ recipe.title }}</h3>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-layout row text-xs-center wrap>
|
||||
<v-flex xs12 v-if="recipe.short_description">
|
||||
<p>{{ recipe.short_description }}</p>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
<p><v-icon>timelapse</v-icon> {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
<p><v-icon>whatshot</v-icon> {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as api from '@/api';
|
||||
|
||||
import ErrorDialog from '@/components/ErrorDialog';
|
||||
import Loader from '@/components/Loader';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorDialog,
|
||||
Loader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
recipes: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchRecipes();
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
$route: 'fetchRecipes',
|
||||
},
|
||||
methods: {
|
||||
fetchRecipes() {
|
||||
this.isLoading = true;
|
||||
|
||||
api.loadRecipes()
|
||||
.then((response) => {
|
||||
this.recipes = response.recipes;
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.error = error;
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
timeOrUnknown(time) {
|
||||
if (time !== null && time !== undefined) {
|
||||
return time;
|
||||
}
|
||||
return '?';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<v-container text-xs-center v-if="isImporting">
|
||||
<v-layout row wrap>
|
||||
<Loader></Loader>
|
||||
|
||||
<v-flex xs12>
|
||||
<p>{{ $t('new.importing') }}</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
<v-container text-xs-center v-else class="panel">
|
||||
<v-layout row wrap>
|
||||
<ImportForm v-model="isImporting"></ImportForm>
|
||||
</v-layout>
|
||||
<v-layout row wrap mt-5>
|
||||
<EditForm v-model="isImporting"></EditForm>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EditForm from '@/components/EditForm';
|
||||
import ImportForm from '@/components/ImportForm';
|
||||
import Loader from '@/components/Loader';
|
||||
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditForm,
|
||||
ImportForm,
|
||||
Loader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isImporting: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<v-container grid-list-md class="panel">
|
||||
<Loader v-if="isLoading"></Loader>
|
||||
<v-layout row v-else>
|
||||
<ErrorDialog v-model="errorDelete" :description="$t('error.unable_delete_recipe')" />
|
||||
<ErrorDialog v-model="errorFetch" :description="$t('error.unable_fetch_recipe')" />
|
||||
<ErrorDialog v-model="errorRefetch" :description="$t('error.unable_refetch_recipe')" />
|
||||
|
||||
<v-dialog v-model="refetchConfirm" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ $t('recipe.refetch_recipe') }}</v-card-title>
|
||||
<v-card-text>
|
||||
{{ $t('recipe.refetch_recipe_description') }}
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="secondary" flat @click.stop="refetchConfirm=false">{{ $t('misc.cancel') }}</v-btn>
|
||||
<v-btn color="error" flat @click.stop="handleRefetch">{{ $t('recipe.refetch') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="deleteConfirm" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="headline">{{ $t('recipe.delete_recipe') }}</v-card-title>
|
||||
<v-card-text>{{ $t('recipe.delete_recipe_description') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="secondary" flat @click.stop="deleteConfirm=false">{{ $t('misc.cancel') }}</v-btn>
|
||||
<v-btn color="error" flat @click.stop="handleDelete">{{ $t('recipe.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> {{ $t('recipe.preparation') }} {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
|
||||
<p><v-icon>whatshot</v-icon> {{ $t('recipe.cooking') }} {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<p>{{ recipe.short_description }}</p>
|
||||
|
||||
<h2>{{ $t('recipe.ingredients') }}</h2>
|
||||
<ul class="ml-5" v-if="recipe.ingredients && recipe.ingredients.length">
|
||||
<li v-for="ingredient in recipe.ingredients">
|
||||
{{ ingredient }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
|
||||
|
||||
<h2 class="mt-3">{{ $t('recipe.instructions') }}</h2>
|
||||
<p v-for="item in recipe.instructions">
|
||||
{{ item }}
|
||||
</p>
|
||||
<p class="text-xs-center">
|
||||
<v-btn :href="recipe.url" :title="$t('recipe.website')" v-if="recipe.url">
|
||||
<v-icon class="fa-icon">fa-external-link</v-icon>
|
||||
</v-btn>
|
||||
<v-btn :to="{name: 'Edit', params: { recipeId: recipe.id }}" :title="$t('recipe.edit')">
|
||||
<v-icon>edit</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.stop="deleteConfirm = true" :title="$t('recipe.delete')">
|
||||
<v-icon>delete</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.stop="refetchConfirm = true" :title="$t('recipe.refetch')">
|
||||
<v-icon>autorenew</v-icon>
|
||||
</v-btn>
|
||||
</p>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as api from '@/api';
|
||||
|
||||
import ErrorDialog from '@/components/ErrorDialog';
|
||||
import Loader from '@/components/Loader';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorDialog,
|
||||
Loader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
recipe: null,
|
||||
errorFetch: null,
|
||||
errorDelete: null,
|
||||
errorRefetch: null,
|
||||
deleteConfirm: false,
|
||||
refetchConfirm: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.loadRecipe();
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
$route: 'loadRecipe',
|
||||
},
|
||||
methods: {
|
||||
timeOrUnknown(time) {
|
||||
if (time !== null && time !== undefined) {
|
||||
return time;
|
||||
}
|
||||
return '?';
|
||||
},
|
||||
handleRecipesResponse(response) {
|
||||
if (response.recipes.length < 1) {
|
||||
this.$router.replace({
|
||||
name: 'Home',
|
||||
});
|
||||
}
|
||||
this.recipe = response.recipes[0];
|
||||
this.isLoading = false;
|
||||
},
|
||||
loadRecipe() {
|
||||
this.isLoading = true;
|
||||
|
||||
api.loadRecipe(this.$route.params.recipeId)
|
||||
.then(this.handleRecipesResponse)
|
||||
.catch((error) => {
|
||||
this.isLoading = false;
|
||||
this.errorFetch = error;
|
||||
});
|
||||
},
|
||||
handleDelete() {
|
||||
this.isLoading = true;
|
||||
this.deleteConfirm = false;
|
||||
api.deleteRecipe(this.$route.params.recipeId)
|
||||
.then(() => this.$router.replace('/'))
|
||||
.catch((error) => {
|
||||
this.isLoading = false;
|
||||
this.errorDelete = error;
|
||||
});
|
||||
},
|
||||
handleRefetch() {
|
||||
this.isLoading = true;
|
||||
this.refetchConfirm = false;
|
||||
|
||||
api.refetchRecipe(this.$route.params.recipeId)
|
||||
.then(this.handleRecipesResponse)
|
||||
.catch((error) => {
|
||||
this.isLoading = false;
|
||||
this.errorRefetch = error;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fa-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
|
@ -12,14 +12,16 @@ from weboob.core.ouiboube import WebNip
|
|||
from cuizin import db
|
||||
|
||||
# List of backends with recipe abilities in Weboob
|
||||
BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette']
|
||||
BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'journaldesfemmes', 'marmiton',
|
||||
'supertoinette']
|
||||
|
||||
|
||||
def add_recipe(url):
|
||||
def fetch_recipe(url, recipe=None):
|
||||
"""
|
||||
Add a recipe, trying to scrape from a given URL.
|
||||
|
||||
:param url: URL of the recipe.
|
||||
:param recipe: An optional recipe object to update.
|
||||
:return: A ``cuizin.db.Recipe`` model.
|
||||
"""
|
||||
# Eventually load modules from a local clone
|
||||
|
@ -36,22 +38,20 @@ def add_recipe(url):
|
|||
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
|
||||
recipe = None
|
||||
for backend in backends:
|
||||
browser = backend.browser
|
||||
if url.startswith(browser.BASEURL):
|
||||
browser.location(url)
|
||||
recipe = db.Recipe.from_weboob(browser.page.get_recipe())
|
||||
# Ensure URL is set
|
||||
recipe.url = url
|
||||
recipe.update_from_weboob(browser.page.get_recipe())
|
||||
break
|
||||
|
||||
# If we could not scrape anything, simply create an empty recipe storing
|
||||
# the URL.
|
||||
if not recipe:
|
||||
recipe = db.Recipe()
|
||||
recipe.url = url
|
||||
# Ensure URL is set
|
||||
recipe.url = url
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>cuizin</title>
|
||||
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
@ -2,16 +2,17 @@ import json
|
|||
import os
|
||||
|
||||
import bottle
|
||||
import peewee
|
||||
|
||||
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__))
|
||||
|
||||
|
||||
app = bottle.Bottle()
|
||||
|
||||
@app.hook('after_request')
|
||||
@app.hook('before_request')
|
||||
def enable_cors():
|
||||
"""
|
||||
Add CORS headers at each request.
|
||||
|
@ -49,35 +50,55 @@ def api_v1_recipes():
|
|||
|
||||
return {
|
||||
'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())
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/v1/recipes')
|
||||
def api_v1_recipes_post():
|
||||
@app.post('/api/v1/recipes/by_url')
|
||||
def api_v1_recipes_post_by_url():
|
||||
"""
|
||||
Create a new recipe from URL
|
||||
"""
|
||||
data = json.load(bottle.request.body)
|
||||
data = json.loads(bottle.request.body.read().decode('utf-8'))
|
||||
if 'url' not in data:
|
||||
return {
|
||||
'error': 'No URL provided'
|
||||
}
|
||||
|
||||
recipes = []
|
||||
try:
|
||||
# Try to add
|
||||
return {
|
||||
'recipes': [fetch_recipe(data['url']).to_dict()]
|
||||
}
|
||||
except peewee.IntegrityError:
|
||||
# Redirect to pre-existing recipe if already there
|
||||
recipe = db.Recipe.select().where(
|
||||
db.Recipe.url == data['url']
|
||||
).first()
|
||||
assert recipe
|
||||
recipes = [recipe.to_dict()]
|
||||
except AssertionError:
|
||||
recipes = [add_recipe(data['url']).to_dict()]
|
||||
bottle.redirect('/api/v1/recipe/%s' % recipe.id, 301)
|
||||
|
||||
return {
|
||||
'recipes': recipes
|
||||
}
|
||||
|
||||
@app.post('/api/v1/recipes/manually')
|
||||
def api_v1_recipes_post_manual():
|
||||
"""
|
||||
Create a new recipe manually
|
||||
"""
|
||||
data = json.loads(bottle.request.body.read().decode('utf-8'))
|
||||
|
||||
try:
|
||||
# Try to add
|
||||
recipe = db.Recipe()
|
||||
recipe.update_from_dict(data)
|
||||
recipe.save()
|
||||
return {
|
||||
'recipes': [recipe.to_dict()]
|
||||
}
|
||||
except peewee.IntegrityError:
|
||||
return {
|
||||
'error': 'Duplicate recipe.'
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS'])
|
||||
|
@ -98,6 +119,56 @@ def api_v1_recipe(id):
|
|||
}
|
||||
|
||||
|
||||
@app.route('/api/v1/recipe/:id', ['POST', 'OPTIONS'])
|
||||
def api_v1_recipe_edit(id):
|
||||
"""
|
||||
Edit a given recipe from db
|
||||
"""
|
||||
# CORS
|
||||
if bottle.request.method == 'OPTIONS':
|
||||
return ''
|
||||
|
||||
data = json.loads(bottle.request.body.read().decode('utf-8'))
|
||||
|
||||
recipe = db.Recipe.select().where(
|
||||
db.Recipe.id == id
|
||||
).first()
|
||||
if not recipe:
|
||||
return bottle.abort(400, 'No recipe with id %s.' % id)
|
||||
recipe.update_from_dict(data)
|
||||
recipe.save()
|
||||
|
||||
return {
|
||||
'recipes': [
|
||||
recipe.to_dict()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@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:
|
||||
return bottle.abort(400, 'No recipe with id %s.' % id)
|
||||
|
||||
recipe = fetch_recipe(recipe.url, recipe=recipe)
|
||||
|
||||
return {
|
||||
'recipes': [
|
||||
recipe.to_dict()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.delete('/api/v1/recipe/:id', ['DELETE', 'OPTIONS'])
|
||||
def api_v1_recipe_delete(id):
|
||||
"""
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import base64
|
||||
import os
|
||||
|
||||
import magic
|
||||
|
||||
from peewee import TextField
|
||||
from playhouse.migrate import (
|
||||
SqliteDatabase, SqliteMigrator, migrate
|
||||
)
|
||||
SCRIPT_DIR = os.path.dirname(__file__)
|
||||
|
||||
|
||||
def update_picture(picture):
|
||||
if not picture:
|
||||
return picture
|
||||
|
||||
picture_mime = (
|
||||
'data:%s;base64' % magic.from_buffer(picture,
|
||||
mime=True)
|
||||
)
|
||||
return '%s,%s' % (
|
||||
picture_mime,
|
||||
base64.b64encode(picture).decode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def run_migration():
|
||||
recipes_db = SqliteDatabase(os.path.join(SCRIPT_DIR, '../recipes.db'))
|
||||
migrator = SqliteMigrator(recipes_db)
|
||||
|
||||
new_picture_field = TextField(null=True)
|
||||
updated_pictures = [
|
||||
(recipe_id, update_picture(picture))
|
||||
for (recipe_id, picture) in recipes_db.execute_sql(
|
||||
'SELECT id, picture FROM recipe'
|
||||
)
|
||||
]
|
||||
|
||||
migrate(
|
||||
migrator.drop_column('recipe', 'picture'),
|
||||
migrator.add_column('recipe', 'picture', new_picture_field),
|
||||
)
|
||||
|
||||
for (recipe_id, picture) in updated_pictures:
|
||||
if picture:
|
||||
recipes_db.execute_sql('UPDATE recipe SET picture=? WHERE id=?',
|
||||
(picture, recipe_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
|
@ -12,8 +12,12 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"es6-promise": "^4.2.4",
|
||||
"font-awesome": "^4.7.0",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"material-design-icons": "^3.0.1",
|
||||
"roboto-fontface": "^0.9.0",
|
||||
"vue": "^2.5.2",
|
||||
"vue-i18n": "^7.4.2",
|
||||
"vue-router": "^3.0.1",
|
||||
"vuetify": "^1.0.0"
|
||||
},
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
After Width: | Height: | Size: 459 KiB |
Binary file not shown.
After Width: | Height: | Size: 751 KiB |
Loading…
Reference in New Issue