Update UI
This commit is contained in:
parent
1170bb39c0
commit
746f683cab
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
*.swp
|
||||
*.pyc
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
*.db
|
||||
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
@ -11,9 +11,6 @@ BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette']
|
||||
|
||||
|
||||
def add_recipe(url, modules_path=None):
|
||||
db.database.connect()
|
||||
db.database.create_tables([db.Recipe])
|
||||
|
||||
webnip = WebNip(modules_path=modules_path)
|
||||
|
||||
backends = [
|
||||
@ -25,9 +22,20 @@ def add_recipe(url, modules_path=None):
|
||||
for module in BACKENDS
|
||||
]
|
||||
|
||||
recipe = None
|
||||
for backend in backends:
|
||||
browser = backend.browser
|
||||
if url.startswith(browser.BASEURL):
|
||||
browser.location(url)
|
||||
db.Recipe.from_weboob(browser.page.get_recipe()).save()
|
||||
recipe = db.Recipe.from_weboob(browser.page.get_recipe())
|
||||
# Ensure URL is set
|
||||
recipe.url = url
|
||||
break
|
||||
|
||||
if not recipe:
|
||||
# TODO
|
||||
recipe = db.Recipe()
|
||||
recipe.url = url
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
@ -1,5 +1,8 @@
|
||||
import os
|
||||
|
||||
import peewee
|
||||
|
||||
from cuizin import db
|
||||
from cuizin import web
|
||||
|
||||
|
||||
@ -9,4 +12,10 @@ if __name__ == '__main__':
|
||||
HOST = os.environ.get('CUIZIN_HOST', 'localhost')
|
||||
PORT = os.environ.get('CUIZIN_PORT', '8080')
|
||||
DEBUG = os.environ.get('CUIZIN_DEBUG', False)
|
||||
|
||||
try:
|
||||
db.database.create_tables([db.Recipe])
|
||||
except peewee.OperationalError:
|
||||
pass
|
||||
|
||||
app.run(host=HOST, port=PORT, debug=DEBUG)
|
||||
|
17
cuizin/db.py
17
cuizin/db.py
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
import requests
|
||||
from peewee import (
|
||||
@ -8,11 +9,13 @@ from peewee import (
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
|
||||
database = SqliteDatabase('recipes.db')
|
||||
database = SqliteDatabase('recipes.db', threadlocals=True)
|
||||
database.connect()
|
||||
|
||||
|
||||
class Recipe(Model):
|
||||
title = CharField()
|
||||
url = CharField(null=True, unique=True)
|
||||
author = CharField(null=True)
|
||||
picture = BlobField(null=True)
|
||||
short_description = TextField(null=True)
|
||||
@ -29,7 +32,7 @@ class Recipe(Model):
|
||||
@staticmethod
|
||||
def from_weboob(obj):
|
||||
recipe = Recipe()
|
||||
for field in ['title', 'author', 'picture_url', 'short_description',
|
||||
for field in ['title', 'url', 'author', 'picture_url', 'short_description',
|
||||
'preparation_time', 'cooking_time', 'instructions']:
|
||||
value = getattr(obj, field)
|
||||
if value:
|
||||
@ -39,7 +42,11 @@ class Recipe(Model):
|
||||
|
||||
def to_dict(self):
|
||||
serialized = model_to_dict(self)
|
||||
serialized['picture'] = base64.b64encode(
|
||||
serialized['picture']
|
||||
).decode('utf-8')
|
||||
prepend_info = (
|
||||
'data:%s;base64' % mimetypes.guess_type(serialized['picture'])[0]
|
||||
)
|
||||
serialized['picture'] = '%s,%s' % (
|
||||
prepend_info,
|
||||
base64.b64encode(serialized['picture']).decode('utf-8')
|
||||
)
|
||||
return serialized
|
||||
|
@ -1,91 +1,31 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer
|
||||
persistent
|
||||
:mini-variant="miniVariant"
|
||||
:clipped="clipped"
|
||||
v-model="drawer"
|
||||
enable-resize-watcher
|
||||
fixed
|
||||
app
|
||||
>
|
||||
<v-list>
|
||||
<v-list-tile
|
||||
value="true"
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
>
|
||||
<v-list-tile-action>
|
||||
<v-icon v-html="item.icon"></v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title v-text="item.title"></v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
<v-toolbar
|
||||
app
|
||||
:clipped-left="clipped"
|
||||
>
|
||||
<v-toolbar-side-icon @click.stop="drawer = !drawer"></v-toolbar-side-icon>
|
||||
<v-btn icon @click.stop="miniVariant = !miniVariant">
|
||||
<v-icon v-html="miniVariant ? 'chevron_right' : 'chevron_left'"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click.stop="clipped = !clipped">
|
||||
<v-icon>web</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click.stop="fixed = !fixed">
|
||||
<v-icon>remove</v-icon>
|
||||
</v-btn>
|
||||
<v-toolbar-title v-text="title"></v-toolbar-title>
|
||||
<v-toolbar app>
|
||||
<v-toolbar-title class="toolbar-title">
|
||||
<router-link to="/">Cuizin</router-link>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click.stop="rightDrawer = !rightDrawer">
|
||||
<v-icon>menu</v-icon>
|
||||
<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-navigation-drawer
|
||||
temporary
|
||||
:right="right"
|
||||
v-model="rightDrawer"
|
||||
fixed
|
||||
app
|
||||
>
|
||||
<v-list>
|
||||
<v-list-tile @click="right = !right">
|
||||
<v-list-tile-action>
|
||||
<v-icon>compare_arrows</v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-title>Switch drawer (click me)</v-list-tile-title>
|
||||
</v-list-tile>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
<v-footer :fixed="fixed" app>
|
||||
<span>© 2017</span>
|
||||
</v-footer>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
clipped: false,
|
||||
drawer: true,
|
||||
fixed: false,
|
||||
items: [{
|
||||
icon: 'bubble_chart',
|
||||
title: 'Inspire',
|
||||
}],
|
||||
miniVariant: false,
|
||||
right: true,
|
||||
rightDrawer: false,
|
||||
title: 'Vuetify.js',
|
||||
};
|
||||
},
|
||||
name: 'App',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toolbar-title a {
|
||||
color: rgba(0,0,0,.87);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-slide-y-transition mode="out-in">
|
||||
<v-layout column align-center>
|
||||
<img src="@/assets/logo.png" alt="Vuetify.js" class="mb-5">
|
||||
<blockquote>
|
||||
“First, solve the problem. Then, write the code.”
|
||||
<footer>
|
||||
<small>
|
||||
<em>—John Johnson</em>
|
||||
</small>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</v-layout>
|
||||
</v-slide-y-transition>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h1, h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
61
cuizin/js_src/components/Home.vue
Normal file
61
cuizin/js_src/components/Home.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<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>
|
144
cuizin/js_src/components/New.vue
Normal file
144
cuizin/js_src/components/New.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<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-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';
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
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>
|
45
cuizin/js_src/components/Recipe.vue
Normal file
45
cuizin/js_src/components/Recipe.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<v-container grid-list-md>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs12 v-if="this.recipe">
|
||||
<h1>{{ this.recipe.title }}</h1>
|
||||
<p><img :src="this.recipe.picture" height="300px" /></p>
|
||||
<p>{{ this.recipe.short_description }}</p>
|
||||
<p>{{ this.recipe.ingredients }}</p>
|
||||
<p>{{ this.recipe.instructions }}</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;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
2
cuizin/js_src/constants.js
Normal file
2
cuizin/js_src/constants.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const API_URL = 'http://localhost:8080';
|
||||
export const FOOBAR = 'TODO'; // TODO
|
@ -7,6 +7,10 @@ import 'vuetify/dist/vuetify.min.css';
|
||||
import App from './App';
|
||||
import router from './router';
|
||||
|
||||
// Isomorphic fetch
|
||||
require('es6-promise').polyfill();
|
||||
require('isomorphic-fetch');
|
||||
|
||||
Vue.use(Vuetify);
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
import HelloWorld from '@/components/HelloWorld';
|
||||
import Home from '@/components/Home';
|
||||
import New from '@/components/New';
|
||||
import Recipe from '@/components/Recipe';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
@ -8,8 +10,18 @@ export default new Router({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'HelloWorld',
|
||||
component: HelloWorld,
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/new',
|
||||
name: 'New',
|
||||
component: New,
|
||||
},
|
||||
{
|
||||
path: '/recipe/:recipeId',
|
||||
name: 'Recipe',
|
||||
component: Recipe,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -1,20 +1,43 @@
|
||||
import bottle
|
||||
import json
|
||||
|
||||
import bottle
|
||||
import peewee
|
||||
|
||||
from cuizin import add_recipe
|
||||
from cuizin import db
|
||||
|
||||
|
||||
bottle.Bottle()
|
||||
app = bottle.Bottle()
|
||||
|
||||
@app.hook('after_request')
|
||||
def enable_cors():
|
||||
"""
|
||||
Add CORS headers at each request.
|
||||
"""
|
||||
# The str() call is required as we import unicode_literal and WSGI
|
||||
# headers list should have plain str type.
|
||||
bottle.response.headers[str('Access-Control-Allow-Origin')] = str('*')
|
||||
bottle.response.headers[str('Access-Control-Allow-Methods')] = str(
|
||||
'PUT, GET, POST, DELETE, OPTIONS, PATCH'
|
||||
)
|
||||
bottle.response.headers[str('Access-Control-Allow-Headers')] = str(
|
||||
'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token'
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/v1')
|
||||
@app.route('/api/v1', ['GET', 'OPTIONS'])
|
||||
def api_v1_index():
|
||||
return {
|
||||
'recipes': '/api/v1/recipes'
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/v1/recipes')
|
||||
@app.route('/api/v1/recipes', ['GET', 'OPTIONS'])
|
||||
def api_v1_recipes():
|
||||
# CORS
|
||||
if bottle.request.method == 'OPTIONS':
|
||||
return ''
|
||||
|
||||
return {
|
||||
'recipes': [
|
||||
recipe.to_dict() for recipe in db.Recipe.select()
|
||||
@ -22,8 +45,33 @@ def api_v1_recipes():
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/v1/recipe/:id')
|
||||
@app.post('/api/v1/recipes')
|
||||
def api_v1_recipes_post():
|
||||
data = json.load(bottle.request.body)
|
||||
if 'url' not in data:
|
||||
return {
|
||||
'error': 'No URL provided'
|
||||
}
|
||||
|
||||
recipes = []
|
||||
try:
|
||||
recipes = [add_recipe(data['url']).to_dict()]
|
||||
except peewee.IntegrityError:
|
||||
recipes = [db.Recipe.select().where(
|
||||
db.Recipe.url == data['url']
|
||||
).first().to_dict()]
|
||||
|
||||
return {
|
||||
'recipes': recipes
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS'])
|
||||
def api_v1_recipe(id):
|
||||
# CORS
|
||||
if bottle.request.method == 'OPTIONS':
|
||||
return ''
|
||||
|
||||
return {
|
||||
'recipes': [
|
||||
recipe.to_dict() for recipe in db.Recipe.select().where(
|
||||
@ -36,4 +84,4 @@ def api_v1_recipe(id):
|
||||
@app.get('/static/<filename:path>')
|
||||
def get_static_files(filename):
|
||||
"""Get Static files"""
|
||||
return bottle.static_file(filename, root=)
|
||||
return bottle.static_file(filename) # TODO: root=
|
||||
|
@ -11,6 +11,8 @@
|
||||
"build": "node build/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"es6-promise": "^4.2.4",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"vue": "^2.5.2",
|
||||
"vue-router": "^3.0.1",
|
||||
"vuetify": "^1.0.0"
|
||||
|
BIN
recipes.db
BIN
recipes.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user