recipe editing
This commit is contained in:
parent
5579876c63
commit
1c4fa37778
@ -3,6 +3,14 @@ class ApplicationController < ActionController::Base
|
|||||||
# For APIs, you may want to use :null_session instead.
|
# For APIs, you may want to use :null_session instead.
|
||||||
protect_from_forgery with: :exception
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
def verified_request?
|
||||||
|
if request.content_type == "application/json"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
super()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_valid_user
|
def ensure_valid_user
|
||||||
unless current_user?
|
unless current_user?
|
||||||
flash[:warning] = "You must login"
|
flash[:warning] = "You must login"
|
||||||
|
@ -6,7 +6,7 @@ class RecipesController < ApplicationController
|
|||||||
|
|
||||||
# GET /recipes
|
# GET /recipes
|
||||||
def index
|
def index
|
||||||
@criteria = ViewModels::RecipeCriteria.new(params[:criteria])
|
@criteria = ViewModels::RecipeCriteria.new(criteria_params)
|
||||||
@criteria.page = params[:page]
|
@criteria.page = params[:page]
|
||||||
@criteria.per = params[:per]
|
@criteria.per = params[:per]
|
||||||
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
|
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
|
||||||
@ -40,26 +40,6 @@ class RecipesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@recipe = RecipeDecorator.decorate(@recipe, view_context)
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
# GET /recipes/1
|
|
||||||
def scale
|
|
||||||
@scale = params[:factor]
|
|
||||||
@recipe.scale(@scale, true)
|
|
||||||
@recipe = RecipeDecorator.decorate(@recipe, view_context)
|
|
||||||
render :show
|
|
||||||
end
|
|
||||||
|
|
||||||
# GET /recipes/new
|
|
||||||
def new
|
|
||||||
@recipe = Recipe.new
|
|
||||||
end
|
|
||||||
|
|
||||||
# GET /recipes/1/edit
|
|
||||||
def edit
|
|
||||||
ensure_owner @recipe
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /recipes
|
# POST /recipes
|
||||||
@ -68,9 +48,9 @@ class RecipesController < ApplicationController
|
|||||||
@recipe.user = current_user
|
@recipe.user = current_user
|
||||||
|
|
||||||
if @recipe.save
|
if @recipe.save
|
||||||
redirect_to @recipe, notice: 'Recipe was successfully created.'
|
render json: { success: true }
|
||||||
else
|
else
|
||||||
render :new
|
render json: @recipe.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -78,13 +58,20 @@ class RecipesController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
ensure_owner(@recipe) do
|
ensure_owner(@recipe) do
|
||||||
if @recipe.update(recipe_params)
|
if @recipe.update(recipe_params)
|
||||||
redirect_to @recipe, notice: 'Recipe was successfully updated.'
|
render json: { success: true }
|
||||||
else
|
else
|
||||||
render :edit
|
puts '===='
|
||||||
|
puts @recipe.recipe_ingredients.map { |ri| ri.ingredient_id.class }.join("|")
|
||||||
|
render json: @recipe.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /recipes/preview_steps
|
||||||
|
def preview_steps
|
||||||
|
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
|
||||||
|
end
|
||||||
|
|
||||||
# DELETE /recipes/1
|
# DELETE /recipes/1
|
||||||
def destroy
|
def destroy
|
||||||
ensure_owner(@recipe) do
|
ensure_owner(@recipe) do
|
||||||
@ -108,4 +95,8 @@ class RecipesController < ApplicationController
|
|||||||
def recipe_params
|
def recipe_params
|
||||||
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
|
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def criteria_params
|
||||||
|
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<vue-progress-bar></vue-progress-bar>
|
||||||
<app-navbar></app-navbar>
|
<app-navbar></app-navbar>
|
||||||
<section id="app" class="section">
|
<section id="app" class="">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<router-view v-if="!hasError"></router-view>
|
<router-view v-if="!hasError"></router-view>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@ -20,6 +21,11 @@
|
|||||||
import api from "../lib/Api";
|
import api from "../lib/Api";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
api: api
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
hasError: state => state.error !== null,
|
hasError: state => state.error !== null,
|
||||||
@ -34,11 +40,40 @@
|
|||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
watch: {
|
||||||
|
isLoading(val) {
|
||||||
|
if (val) {
|
||||||
|
this.$Progress.start();
|
||||||
|
} else {
|
||||||
|
this.$Progress.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
if (this.user === null && this.authChecked === false) {
|
if (this.user === null && this.authChecked === false) {
|
||||||
this.loadResource(api.getCurrentUser(), user => {
|
this.loadResource(api.getCurrentUser().then(user => this.setUser(user)));
|
||||||
this.setUser(user);
|
}
|
||||||
})
|
|
||||||
|
// Hard coded values taken directly from Bulma css
|
||||||
|
const mediaQueries = {
|
||||||
|
mobile: "screen and (max-width: 768px)",
|
||||||
|
tablet: "screen and (min-width: 769px)",
|
||||||
|
tabletOnly: "screen and (min-width: 769px) and (max-width: 1023px)",
|
||||||
|
touch: "screen and (max-width: 1023px)",
|
||||||
|
desktop: "screen and (min-width: 1024px)",
|
||||||
|
desktopOnly: "screen and (min-width: 1024px) and (max-width: 1215px)",
|
||||||
|
widescreen: "screen and (min-width: 1216px)",
|
||||||
|
widescreenOnly: "screen and (min-width: 1216px) and (max-width: 1407px)",
|
||||||
|
fullhd: "screen and (min-width: 1408px)"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let device in mediaQueries) {
|
||||||
|
const query = window.matchMedia(mediaQueries[device]);
|
||||||
|
query.onchange = (q) => {
|
||||||
|
this.$store.commit("setMediaQuery", {mediaName: device, value: q.matches});
|
||||||
|
};
|
||||||
|
query.onchange(query);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
258
app/javascript/components/AppAutocomplete.vue
Normal file
258
app/javascript/components/AppAutocomplete.vue
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref="textInput"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
|
||||||
|
:id="id"
|
||||||
|
:name="name"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:value="rawValue"
|
||||||
|
:class="finalInputClass"
|
||||||
|
|
||||||
|
@blur="blurHandler"
|
||||||
|
@input="inputHandler"
|
||||||
|
@keydown="keydownHandler"
|
||||||
|
/>
|
||||||
|
<div v-show="isListOpen" class="list">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(opt, idx) in options" :key="optionKey(opt)" :class="optionClass(idx)" @mousemove="optionMousemove(idx)" @click="optionClick(opt)">
|
||||||
|
<b class="opt_value">{{ optionValue(opt) }}</b>
|
||||||
|
<span v-if="optionLabel(opt) !== null" class="opt_label" v-html="optionLabel(opt)"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
id: String,
|
||||||
|
placeholder: String,
|
||||||
|
name: String,
|
||||||
|
inputClass: {
|
||||||
|
type: [String, Object, Array],
|
||||||
|
required: false,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
debounce: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: 250
|
||||||
|
},
|
||||||
|
|
||||||
|
valueAttribute: String,
|
||||||
|
labelAttribute: String,
|
||||||
|
|
||||||
|
onGetOptions: Function,
|
||||||
|
searchOptions: Array
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
options: [],
|
||||||
|
rawValue: "",
|
||||||
|
isListOpen: false,
|
||||||
|
activeListIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.rawValue = this.value;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
value(newValue) {
|
||||||
|
this.rawValue = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
finalInputClass() {
|
||||||
|
let cls = ['input'];
|
||||||
|
if (this.inputClass === null) {
|
||||||
|
return cls;
|
||||||
|
} else if (Array.isArray(this.inputClass)) {
|
||||||
|
return cls.concat(this.inputClass);
|
||||||
|
} else {
|
||||||
|
cls.push(this.inputClass);
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
debouncedUpdateOptions() {
|
||||||
|
return debounce(this.updateOptions, this.debounce);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
optionClass(idx) {
|
||||||
|
return this.activeListIndex === idx ? 'option active' : 'option';
|
||||||
|
},
|
||||||
|
|
||||||
|
optionClick(opt) {
|
||||||
|
this.selectOption(opt);
|
||||||
|
},
|
||||||
|
|
||||||
|
optionKey(opt) {
|
||||||
|
if (this.valueAttribute) {
|
||||||
|
return opt[this.valueAttribute];
|
||||||
|
} else {
|
||||||
|
return opt.toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
optionValue(opt) {
|
||||||
|
return this.optionKey(opt);
|
||||||
|
},
|
||||||
|
|
||||||
|
optionLabel(opt) {
|
||||||
|
if (this.labelAttribute) {
|
||||||
|
return opt[this.labelAttribute];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
optionMousemove(idx) {
|
||||||
|
this.activeListIndex = idx;
|
||||||
|
},
|
||||||
|
|
||||||
|
blurHandler(evt) {
|
||||||
|
// blur fires before click. If the blur was fired because the user clicked a list item, immediately hiding the list here
|
||||||
|
// would prevent the click event from firing
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isListOpen = false;
|
||||||
|
},250);
|
||||||
|
},
|
||||||
|
|
||||||
|
inputHandler(evt) {
|
||||||
|
const newValue = evt.target.value;
|
||||||
|
|
||||||
|
if (this.rawValue !== newValue) {
|
||||||
|
|
||||||
|
this.rawValue = newValue;
|
||||||
|
|
||||||
|
this.$emit("input", newValue);
|
||||||
|
|
||||||
|
if (newValue.length >= Math.max(1, this.minLength)) {
|
||||||
|
this.debouncedUpdateOptions(newValue);
|
||||||
|
} else {
|
||||||
|
this.isListOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
keydownHandler(evt) {
|
||||||
|
if (this.isListOpen === false)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (evt.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
evt.preventDefault();
|
||||||
|
this.activeListIndex = Math.max(0, this.activeListIndex - 1);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
evt.preventDefault();
|
||||||
|
this.activeListIndex = Math.min(this.options.length - 1, this.activeListIndex + 1);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
evt.preventDefault();
|
||||||
|
this.selectOption(this.options[this.activeListIndex]);
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
evt.preventDefault();
|
||||||
|
this.isListOpen = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectOption(opt) {
|
||||||
|
this.rawValue = this.optionValue(opt);
|
||||||
|
this.$emit("input", this.rawValue);
|
||||||
|
this.$emit("optionSelected", opt);
|
||||||
|
this.isListOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateOptions(value) {
|
||||||
|
let p = null;
|
||||||
|
if (this.searchOptions) {
|
||||||
|
const reg = new RegExp("^" + value, "i");
|
||||||
|
const matcher = o => reg.test(this.optionValue(o));
|
||||||
|
p = Promise.resolve(this.searchOptions.filter(matcher));
|
||||||
|
} else {
|
||||||
|
p = this.onGetOptions(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.then(opts => {
|
||||||
|
this.options = opts;
|
||||||
|
this.isListOpen = opts.length > 0;
|
||||||
|
this.activeListIndex = 0;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
@import "../styles/variables";
|
||||||
|
|
||||||
|
$labelLineHeight: 0.8rem;
|
||||||
|
|
||||||
|
input.input {
|
||||||
|
&::placeholder {
|
||||||
|
color: $grey-darker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 150;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
background-color: white;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li.option {
|
||||||
|
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
//transition: background-color 0.25s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: white;
|
||||||
|
background-color: $turquoise;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt_value {
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt_label {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: $labelLineHeight;
|
||||||
|
max-height: $labelLineHeight * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -6,16 +6,20 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import X from "open-iconic/svg/x";
|
import CaretBottom from "open-iconic/svg/caret-bottom";
|
||||||
import Person from "open-iconic/svg/person";
|
import CaretTop from "open-iconic/svg/caret-top";
|
||||||
import LockLocked from "open-iconic/svg/lock-locked";
|
import LockLocked from "open-iconic/svg/lock-locked";
|
||||||
import LockUnlocked from "open-iconic/svg/lock-unlocked";
|
import LockUnlocked from "open-iconic/svg/lock-unlocked";
|
||||||
|
import Person from "open-iconic/svg/person";
|
||||||
|
import X from "open-iconic/svg/x";
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
x: X,
|
'caret-bottom': CaretBottom,
|
||||||
person: Person,
|
'caret-top': CaretTop,
|
||||||
'lock-locked': LockLocked,
|
'lock-locked': LockLocked,
|
||||||
'lock-unlocked': LockUnlocked
|
'lock-unlocked': LockUnlocked,
|
||||||
|
person: Person,
|
||||||
|
x: X
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
|
@ -1,14 +1,156 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">{{ action }} {{ recipe.name || "[Unamed Recipe]" }}</h1>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Name</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="text" placeholder="name" v-model="recipe.name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Source</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="text" placeholder="source" v-model="recipe.source">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Description</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea is-small-mobile" placeholder="description" v-model="recipe.description"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Tags</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="text" placeholder="tags">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Yields</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="text" placeholder="servings" v-model="recipe.yields">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Total Time</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="number" placeholder="minutes" v-model="recipe.total_time">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Active Time</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small-mobile" type="number" placeholder="minutes" v-model="recipe.active_time">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="title is-4">Ingredients</h3>
|
||||||
|
|
||||||
|
<recipe-edit-ingredient-editor :ingredients="recipe.ingredients"></recipe-edit-ingredient-editor>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label title is-4">Directions</label>
|
||||||
|
<div class="control columns">
|
||||||
|
<div class="column">
|
||||||
|
<textarea ref="step_text_area" class="textarea" v-model="recipe.step_text"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box content" v-html="stepPreview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
export default {
|
import autosize from "autosize";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
import RecipeEditIngredientEditor from "./RecipeEditIngredientEditor";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: "Editing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
stepPreviewCache: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
stepPreview() {
|
||||||
|
if (this.stepPreviewCache === null) {
|
||||||
|
return this.recipe.rendered_steps;
|
||||||
|
} else {
|
||||||
|
return this.stepPreviewCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
updatePreview: debounce(function() {
|
||||||
|
api.postPreviewSteps(this.recipe.step_text)
|
||||||
|
.then(data => this.stepPreviewCache = data.rendered_steps)
|
||||||
|
.catch(err => this.stepPreviewCache = "?? Error ??");
|
||||||
|
}, 750)
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'recipe.step_text': function() {
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
autosize(this.$refs.step_text_area);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEditIngredientEditor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.bulk-input {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
169
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
169
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
|
||||||
|
<app-modal :open="isBulkEditing" title="Edit Ingredients" @dismiss="cancelBulkEditing">
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column is-half">
|
||||||
|
<textarea ref="bulkEditTextarea" class="textarea is-size-7 bulk-input" v-model="bulkEditText"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<table class="table is-bordered is-narrow is-size-7">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Prep</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="i in bulkIngredientPreview">
|
||||||
|
<td>{{i.quantity}}</td>
|
||||||
|
<td>{{i.units}}</td>
|
||||||
|
<td>{{i.name}}</td>
|
||||||
|
<td>{{i.preparation}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" type="button" @click="saveBulkEditing">Save</button>
|
||||||
|
<button class="button is-secondary" type="button" @click="cancelBulkEditing">Cancel</button>
|
||||||
|
</app-modal>
|
||||||
|
|
||||||
|
<recipe-edit-ingredient-item v-for="(i, idx) in visibleIngredients" :key="i.id" :ingredient="i" :show-labels="idx === 0 || isMobile" @deleteIngredient="deleteIngredient"></recipe-edit-ingredient-item>
|
||||||
|
|
||||||
|
<button type="button" class="button is-primary" @click="addIngredient">Add Ingredient</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import AppModal from "./AppModal";
|
||||||
|
import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
|
||||||
|
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ingredients: {
|
||||||
|
required: true,
|
||||||
|
type: Array
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isBulkEditing: false,
|
||||||
|
bulkEditText: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
isMobile: state => state.mediaQueries.mobile
|
||||||
|
}),
|
||||||
|
bulkIngredientPreview() {
|
||||||
|
if (this.bulkEditText === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = /^(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,]*)(?:,\s*(.*))?$/i;
|
||||||
|
|
||||||
|
const magicFunc = function(str) {
|
||||||
|
if (str === "-") {
|
||||||
|
return "";
|
||||||
|
} else {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = [];
|
||||||
|
const lines = this.bulkEditText.replace("\r", "").split("\n");
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
if (line.length === 0) { continue; }
|
||||||
|
|
||||||
|
const barIndex = line.lastIndexOf("|");
|
||||||
|
let afterBar = null;
|
||||||
|
|
||||||
|
if (barIndex >= 0) {
|
||||||
|
afterBar = line.slice(barIndex + 1);
|
||||||
|
line = line.slice(0, barIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
let item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(match[3]), preparation: magicFunc(match[4])};
|
||||||
|
if (afterBar) {
|
||||||
|
item.name = item.name + ", " + item.preparation;
|
||||||
|
item.preparation = afterBar;
|
||||||
|
}
|
||||||
|
parsed.push(item);
|
||||||
|
} else {
|
||||||
|
parsed.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
|
||||||
|
visibleIngredients() {
|
||||||
|
return this.ingredients.filter(i => i._destroy !== true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addIngredient() {
|
||||||
|
this.ingredients.push({
|
||||||
|
quantity: null,
|
||||||
|
units: null,
|
||||||
|
name: null,
|
||||||
|
preparation: null,
|
||||||
|
ingredient_id: null,
|
||||||
|
sort_order: Math.max([0].concat(this.ingredients.map(i => i.sort_order))) + 5
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteIngredient(ingredient) {
|
||||||
|
if (ingredient.id) {
|
||||||
|
ingredient._destroy = true;
|
||||||
|
} else {
|
||||||
|
const idx = this.ingredients.findIndex(i => i === ingredient);
|
||||||
|
this.ingredients.splice(idx, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkEditIngredients() {
|
||||||
|
this.isBulkEditing = true;
|
||||||
|
|
||||||
|
let text = [];
|
||||||
|
|
||||||
|
for (let item of this.visibleIngredients) {
|
||||||
|
text.push(
|
||||||
|
item.quantity + " " +
|
||||||
|
(item.units || "-") + " " +
|
||||||
|
item.name +
|
||||||
|
(item.preparation ? (", " + item.preparation) : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bulkEditText = text.join("\n");
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelBulkEditing() {
|
||||||
|
this.isBulkEditing = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveBulkEditing() {
|
||||||
|
this.isBulkEditing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AppModal,
|
||||||
|
RecipeEditIngredientItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
95
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
95
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column">
|
||||||
|
|
||||||
|
<div class="columns is-multiline is-mobile">
|
||||||
|
<div class="column is-half-mobile is-2-tablet">
|
||||||
|
<span class="label is-small-mobile" v-if="showLabels">Quantity</span>
|
||||||
|
<input class="input is-small-mobile" type="text" v-model="ingredient.quantity">
|
||||||
|
</div>
|
||||||
|
<div class="column is-half-mobile is-3-tablet">
|
||||||
|
<span class="label is-small-mobile" v-if="showLabels">Units</span>
|
||||||
|
<input class="input is-small-mobile" type="text" v-model="ingredient.units">
|
||||||
|
</div>
|
||||||
|
<div class="column is-half-mobile is-3-tablet">
|
||||||
|
<span class="label is-small-mobile" v-if="showLabels">Name</span>
|
||||||
|
|
||||||
|
<app-autocomplete
|
||||||
|
:inputClass="{'is-small-mobile': true, 'is-success': ingredient.ingredient_id !== null}"
|
||||||
|
ref="autocomplete"
|
||||||
|
v-model="ingredient.name"
|
||||||
|
:minLength="2"
|
||||||
|
valueAttribute="name"
|
||||||
|
labelAttribute="name"
|
||||||
|
placeholder=""
|
||||||
|
@optionSelected="searchItemSelected"
|
||||||
|
:onGetOptions="updateSearchItems"
|
||||||
|
>
|
||||||
|
</app-autocomplete>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half-mobile is-4-tablet">
|
||||||
|
<span class="label is-small-mobile" v-if="showLabels">Preparation</span>
|
||||||
|
<input class="input is-small-mobile" type="text" v-model="ingredient.preparation">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<span class="label is-small-mobile" v-if="showLabels"> </span>
|
||||||
|
<button type="button" class="button is-danger is-small-mobile" @click="deleteIngredient(ingredient)">X</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import AppAutocomplete from "./AppAutocomplete";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ingredient: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
showLabels: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
deleteIngredient(ingredient) {
|
||||||
|
this.$emit("deleteIngredient", ingredient);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSearchItems(text) {
|
||||||
|
return api.getSearchIngredients(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
searchItemSelected(ingredient) {
|
||||||
|
this.ingredient.ingredient_id = ingredient.id;
|
||||||
|
this.ingredient.ingredient = ingredient;
|
||||||
|
this.ingredient.name = ingredient.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'ingredient.name': function(val) {
|
||||||
|
if (this.ingredient.ingredient && this.ingredient.ingredient.name !== val) {
|
||||||
|
this.ingredient.ingredient_id = null;
|
||||||
|
this.ingredient.ingredient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AppAutocomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
@ -1,11 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="recipe === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<h1 class="title">{{ recipe.name }}</h1>
|
||||||
|
<div class="subtitle tags">
|
||||||
|
<span v-for="tag in recipe.tags" :key="tag" class="tag is-medium">{{tag}}</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="level is-mobile">
|
||||||
|
<div class="level-item">
|
||||||
|
{{ recipe.total_time}} ({{recipe.active_time}})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-item">
|
||||||
|
<p>Yields</p>
|
||||||
|
<p>{{recipe.yields}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-item">
|
||||||
|
<p>Source</p>
|
||||||
|
<p>{{recipe.source}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">Ingredients</div>
|
||||||
|
<div class="message-body content">
|
||||||
|
<ul v-if="recipe.ingredients.length > 0">
|
||||||
|
<li v-for="i in recipe.ingredients">
|
||||||
|
{{i.display_name}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">Directions</div>
|
||||||
|
<div class="message-body content" v-html="recipe.rendered_steps">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header" @click="showNutrition = !showNutrition">Nutrition Data</div>
|
||||||
|
<div class="message-body" v-show="showNutrition">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name">
|
||||||
|
<td>{{nutrient.label}}</td>
|
||||||
|
<td>{{nutrient.value}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="title is-5">Nutrition Calculation Warnings</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="warn in recipe.nutrition_data.errors" :key="warn">
|
||||||
|
{{warn}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showNutrition: false
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
50
app/javascript/components/TheRecipe.vue
Normal file
50
app/javascript/components/TheRecipe.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="recipe === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<recipe-show :recipe="recipe"></recipe-show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link :to="{name: 'edit_recipe', params: { id: recipeId }}">Edit</router-link>
|
||||||
|
<router-link to="/">Back</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import RecipeShow from "./RecipeShow";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
recipe: null,
|
||||||
|
showNutrition: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
recipeId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getRecipe(this.recipeId)
|
||||||
|
.then(data => { this.recipe = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
46
app/javascript/components/TheRecipeCreator.vue
Normal file
46
app/javascript/components/TheRecipeCreator.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<recipe-edit :recipe="recipe" action="Creating"></recipe-edit>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import RecipeEdit from "./RecipeEdit";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
recipe: {
|
||||||
|
name: null,
|
||||||
|
source: null,
|
||||||
|
description: null,
|
||||||
|
yields: null,
|
||||||
|
total_time: null,
|
||||||
|
active_time: null,
|
||||||
|
step_text: null,
|
||||||
|
tags: [],
|
||||||
|
ingredients: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
recipeId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
63
app/javascript/components/TheRecipeEditor.vue
Normal file
63
app/javascript/components/TheRecipeEditor.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div v-if="recipe === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<recipe-edit :recipe="recipe"></recipe-edit>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save</button>
|
||||||
|
<router-link class="button is-secondary" to="/">Cancel</router-link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import RecipeEdit from "./RecipeEdit";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
recipe: null,
|
||||||
|
validationErrors: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
recipeId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.loadResource(
|
||||||
|
api.patchRecipe(this.recipe)
|
||||||
|
.then(() => this.$router.push({name: 'recipe', params: {id: this.recipeId }}))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getRecipe(this.recipeId)
|
||||||
|
.then(data => { this.recipe = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
@ -2,17 +2,54 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="title">Recipes</h1>
|
<h1 class="title">Recipes</h1>
|
||||||
|
|
||||||
<button type="button" class="button">Create Recipe</button>
|
<router-link :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th v-for="h in tableHeader" :key="h.name">
|
||||||
|
<a v-if="h.sort" href="#" @click.prevent="setSort(h.name)">
|
||||||
|
{{h.label}}
|
||||||
|
<app-icon v-if="search.sortColumn === h.name" size="sm" :icon="search.sortDirection === 'desc' ? 'caret-bottom' : 'caret-top'"></app-icon>
|
||||||
|
</a>
|
||||||
|
<span v-else>{{h.label}}</span>
|
||||||
|
</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input" placeholder="search names" v-model="search.name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input" placeholder="search tags" v-model="search.tags">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td colspan="5"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in recipes" :key="r.id">
|
<tr v-for="r in recipes" :key="r.id">
|
||||||
<td>{{r.name}}</td>
|
<td><router-link :to="{name: 'recipe', params: { id: r.id } }">{{r.name}}</router-link></td>
|
||||||
|
<td>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag" v-for="tag in r.tags" :key="tag">{{tag}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{r.rating}}</td>
|
||||||
|
<td>{{r.yields}}</td>
|
||||||
|
<td>{{r.total_time}} ({{r.active_time}})</td>
|
||||||
|
<td>{{r.created_at}}</td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="{name: 'edit_recipe', params: { id: r.id } }" class="button">Edit</router-link>
|
||||||
|
<button type="button" class="button">Delete</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -23,20 +60,24 @@
|
|||||||
<script>
|
<script>
|
||||||
|
|
||||||
import api from "../lib/Api";
|
import api from "../lib/Api";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import AppIcon from "./AppIcon";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
recipeData: null
|
recipeData: null,
|
||||||
|
search: {
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
page: 1,
|
||||||
|
per: 25,
|
||||||
|
name: null,
|
||||||
|
tags: null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
|
||||||
|
|
||||||
this.loadResource(api.getRecipeList(), data => this.recipeData = data);
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
recipes() {
|
recipes() {
|
||||||
if (this.recipeData) {
|
if (this.recipeData) {
|
||||||
@ -44,7 +85,50 @@
|
|||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
tableHeader() {
|
||||||
|
return [
|
||||||
|
{name: 'name', label: 'Name', sort: true},
|
||||||
|
{name: 'tags', label: 'Tags', sort: false},
|
||||||
|
{name: 'rating', label: 'Rating', sort: true},
|
||||||
|
{name: 'yields', label: 'Yields', sort: false},
|
||||||
|
{name: 'total_time', label: 'Time', sort: true},
|
||||||
|
{name: 'created_at', label: 'Created', sort: true}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
setSort(col) {
|
||||||
|
if (this.search.sortColumn === col) {
|
||||||
|
this.search.sortDirection = this.search.sortDirection === "desc" ? "asc" : "desc";
|
||||||
|
} else {
|
||||||
|
this.search.sortColumn = col;
|
||||||
|
this.search.sortDirection = "desc";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getList: debounce(function() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getRecipeList(this.search.page, this.search.per, this.search.sortColumn, this.search.sortDirection, this.search.name, this.search.tags)
|
||||||
|
.then(data => this.recipeData = data)
|
||||||
|
);
|
||||||
|
}, 500, {leading: true, trailing: true})
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch("search",
|
||||||
|
() => this.getList(),
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
AppIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,14 +75,14 @@
|
|||||||
|
|
||||||
login() {
|
login() {
|
||||||
if (this.username !== '' && this.password != '') {
|
if (this.username !== '' && this.password != '') {
|
||||||
this.loadResource(api.postLogin(this.username, this.password), data => {
|
this.loadResource(api.postLogin(this.username, this.password).then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.setUser(data.user);
|
this.setUser(data.user);
|
||||||
this.showLogin = false;
|
this.showLogin = false;
|
||||||
} else {
|
} else {
|
||||||
this.error = data.message;
|
this.error = data.message;
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -16,6 +16,8 @@ class Api {
|
|||||||
return response;
|
return response;
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
throw new Errors.ApiNotFoundError(response.statusText, response);
|
throw new Errors.ApiNotFoundError(response.statusText, response);
|
||||||
|
} else if (response.status === 422) {
|
||||||
|
return response.json().then(json => { throw new Errors.ApiValidationError(null, response, json) }, jsonErr => { throw new Errors.ApiValidationError(null, response, null) });
|
||||||
} else {
|
} else {
|
||||||
throw new Errors.ApiServerError(response.statusText || "Unknown Server Error", response);
|
throw new Errors.ApiServerError(response.statusText || "Unknown Server Error", response);
|
||||||
}
|
}
|
||||||
@ -45,21 +47,28 @@ class Api {
|
|||||||
return fetch(url, opts).then(this.checkStatus).then(this.parseJSON);
|
return fetch(url, opts).then(this.checkStatus).then(this.parseJSON);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(url, params = {}) {
|
objectToUrlParams(obj, queryParams = [], prefixes = []) {
|
||||||
|
for (let key in obj) {
|
||||||
const queryParams = [];
|
const val = obj[key];
|
||||||
|
const paramName = prefixes.join("[") + "[".repeat(Math.min(prefixes.length, 1)) + encodeURIComponent(key) + "]".repeat(prefixes.length);
|
||||||
for (let key in params) {
|
|
||||||
const val = params[key];
|
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
for (let x of val) {
|
for (let x of val) {
|
||||||
queryParams.push(encodeURIComponent(key) + "[]=" + (x === null ? '' : encodeURIComponent(x)));
|
queryParams.push(paramName + "[]=" + (x === null ? '' : encodeURIComponent(x)));
|
||||||
}
|
}
|
||||||
|
} else if (typeof(val) === "object") {
|
||||||
|
this.objectToUrlParams(val, queryParams, prefixes.concat([key]));
|
||||||
} else {
|
} else {
|
||||||
queryParams.push(encodeURIComponent(key) + "=" + (val === null ? '' : encodeURIComponent(val)));
|
queryParams.push(paramName + "=" + (val === null ? '' : encodeURIComponent(val)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(url, params = {}) {
|
||||||
|
|
||||||
|
const queryParams = this.objectToUrlParams(params);
|
||||||
|
|
||||||
if (queryParams.length) {
|
if (queryParams.length) {
|
||||||
url = url + "?" + queryParams.join("&");
|
url = url + "?" + queryParams.join("&");
|
||||||
}
|
}
|
||||||
@ -71,19 +80,77 @@ class Api {
|
|||||||
return this.performRequest(url, "POST", params);
|
return this.performRequest(url, "POST", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patch(url, params = {}) {
|
||||||
|
return this.performRequest(url, "PATCH", params);
|
||||||
|
}
|
||||||
|
|
||||||
getRecipeList(page, per, sortColumn, sortDirection, name, tags) {
|
getRecipeList(page, per, sortColumn, sortDirection, name, tags) {
|
||||||
const params = {
|
const params = {
|
||||||
page: page || null,
|
criteria: {
|
||||||
per: per || null,
|
page: page || null,
|
||||||
sort_column: sortColumn || null,
|
per: per || null,
|
||||||
sort_direction: sortDirection || null,
|
sort_column: sortColumn || null,
|
||||||
name: name || null,
|
sort_direction: sortDirection || null,
|
||||||
tags: tags || null
|
name: name || null,
|
||||||
|
tags: tags || null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.get("/recipes", params);
|
return this.get("/recipes", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRecipe(id) {
|
||||||
|
return this.get("/recipes/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
patchRecipe(recipe) {
|
||||||
|
const params = {
|
||||||
|
recipe: {
|
||||||
|
name: recipe.name,
|
||||||
|
description: recipe.description,
|
||||||
|
source: recipe.source,
|
||||||
|
yields: recipe.yields,
|
||||||
|
total_time: recipe.total_time,
|
||||||
|
active_time: recipe.active_time,
|
||||||
|
step_text: recipe.step_text,
|
||||||
|
tag_names: recipe.tag_names,
|
||||||
|
recipe_ingredients_attributes: recipe.ingredients.map(i => {
|
||||||
|
if (i._destroy) {
|
||||||
|
return {
|
||||||
|
id: i.id,
|
||||||
|
_destroy: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: i.id,
|
||||||
|
name: i.name,
|
||||||
|
ingredient_id: null,
|
||||||
|
quantity: i.quantity,
|
||||||
|
units: i.units,
|
||||||
|
preparation: i.preparation,
|
||||||
|
sort_order: i.sort_order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.patch("/recipes/" + recipe.id, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
postPreviewSteps(step_text) {
|
||||||
|
const params = {
|
||||||
|
step_text: step_text
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.post("/recipes/preview_steps", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSearchIngredients(query) {
|
||||||
|
const params = { query: query };
|
||||||
|
return this.get("/ingredients/search", params);
|
||||||
|
}
|
||||||
|
|
||||||
postLogin(username, password) {
|
postLogin(username, password) {
|
||||||
const params = {
|
const params = {
|
||||||
username: username,
|
username: username,
|
||||||
|
@ -23,6 +23,7 @@ ApiServerError.prototype = Object.assign(new ApiError(), {
|
|||||||
name: "ApiServerError"
|
name: "ApiServerError"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export function ApiNotFoundError(message, response) {
|
export function ApiNotFoundError(message, response) {
|
||||||
this.message = (message || "Unknown API Server Error Occurred");
|
this.message = (message || "Unknown API Server Error Occurred");
|
||||||
this.response = response;
|
this.response = response;
|
||||||
@ -32,6 +33,40 @@ ApiNotFoundError.prototype = Object.assign(new ApiError(), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export function ApiValidationError(message, response, json) {
|
||||||
|
this.message = (message || "Server returned a validation error");
|
||||||
|
this.response = response;
|
||||||
|
this.json = json;
|
||||||
|
}
|
||||||
|
ApiValidationError.prototype = Object.assign(new ApiError(), {
|
||||||
|
name: "ApiValidationError",
|
||||||
|
|
||||||
|
validationErrors: function() {
|
||||||
|
const errors = new Map();
|
||||||
|
if (this.json) {
|
||||||
|
for (let key in this.json) {
|
||||||
|
errors.set(key, this.json[key]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.set("base", ["unknown error"]);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
},
|
||||||
|
|
||||||
|
formattedValidationErrors: function() {
|
||||||
|
const errors = [];
|
||||||
|
if (this.json) {
|
||||||
|
for (let key in this.json) {
|
||||||
|
errors.push(key + ": " + this.json[key].join(", "));
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
} else {
|
||||||
|
return ["unable to determine validation errors"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export function onlyFor(errorType, handler) {
|
export function onlyFor(errorType, handler) {
|
||||||
return (err) => {
|
return (err) => {
|
||||||
if (err instanceof errorType) {
|
if (err instanceof errorType) {
|
||||||
|
@ -19,11 +19,10 @@ Vue.mixin({
|
|||||||
'setLoading'
|
'setLoading'
|
||||||
]),
|
]),
|
||||||
|
|
||||||
loadResource(promise, successFunc) {
|
loadResource(promise) {
|
||||||
this.setLoading(true);
|
this.setLoading(true);
|
||||||
|
|
||||||
return promise
|
return promise
|
||||||
.then(successFunc)
|
|
||||||
.catch(err => this.setError(err))
|
.catch(err => this.setError(err))
|
||||||
.then(() => this.setLoading(false));
|
.then(() => this.setLoading(false));
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,27 @@ import '../styles';
|
|||||||
|
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { sync } from 'vuex-router-sync';
|
import { sync } from 'vuex-router-sync';
|
||||||
|
import VueProgressBar from "vue-progressbar";
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import store from '../store';
|
import store from '../store';
|
||||||
import router from '../router';
|
import router from '../router';
|
||||||
import '../lib/GlobalMixins';
|
import '../lib/GlobalMixins';
|
||||||
import App from '../components/App';
|
import App from '../components/App';
|
||||||
|
|
||||||
//Vue.use(VueClipboard);
|
Vue.use(VueProgressBar, {
|
||||||
|
// color: '#bffaf3',
|
||||||
|
// failedColor: '#874b4b',
|
||||||
|
// thickness: '5px',
|
||||||
|
// transition: {
|
||||||
|
// speed: '0.2s',
|
||||||
|
// opacity: '0.6s',
|
||||||
|
// termination: 300
|
||||||
|
// },
|
||||||
|
// autoRevert: true,
|
||||||
|
// location: 'left',
|
||||||
|
// inverse: false
|
||||||
|
});
|
||||||
|
|
||||||
sync(store, router);
|
sync(store, router);
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Router from 'vue-router';
|
import Router from 'vue-router';
|
||||||
|
|
||||||
import RecipeEdit from './components/RecipeEdit';
|
|
||||||
import RecipeShow from './components/RecipeShow';
|
|
||||||
import The404Page from './components/The404Page';
|
import The404Page from './components/The404Page';
|
||||||
import TheAboutPage from './components/TheAboutPage';
|
import TheAboutPage from './components/TheAboutPage';
|
||||||
import TheCalculator from './components/TheCalculator';
|
import TheCalculator from './components/TheCalculator';
|
||||||
import TheIngredientList from './components/TheIngredientList';
|
import TheIngredientList from './components/TheIngredientList';
|
||||||
import TheNotesList from './components/TheNotesList';
|
import TheNotesList from './components/TheNotesList';
|
||||||
|
import TheRecipe from './components/TheRecipe';
|
||||||
|
import TheRecipeEditor from './components/TheRecipeEditor';
|
||||||
|
import TheRecipeCreator from './components/TheRecipeCreator';
|
||||||
import TheRecipeList from './components/TheRecipeList';
|
import TheRecipeList from './components/TheRecipeList';
|
||||||
|
|
||||||
Vue.use(Router);
|
Vue.use(Router);
|
||||||
@ -20,18 +21,27 @@ router.addRoutes(
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
redirect: '/recipes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recipes',
|
||||||
name: 'recipeList',
|
name: 'recipeList',
|
||||||
component: TheRecipeList
|
component: TheRecipeList
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/recipes/new',
|
||||||
|
name: 'new_recipe',
|
||||||
|
component: TheRecipeCreator
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/recipes/:id/edit',
|
path: '/recipes/:id/edit',
|
||||||
name: 'edit_recipe',
|
name: 'edit_recipe',
|
||||||
component: RecipeEdit
|
component: TheRecipeEditor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/recipe/:id',
|
path: '/recipe/:id',
|
||||||
name: 'recipe',
|
name: 'recipe',
|
||||||
component: RecipeShow
|
component: TheRecipe
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/about",
|
path: "/about",
|
||||||
|
@ -11,7 +11,20 @@ export default new Vuex.Store({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
authChecked: false,
|
authChecked: false,
|
||||||
user: null
|
user: null,
|
||||||
|
|
||||||
|
// MediaQueryList objects in the root App component maintain this state.
|
||||||
|
mediaQueries: {
|
||||||
|
mobile: false,
|
||||||
|
tablet: false,
|
||||||
|
tabletOnly: false,
|
||||||
|
touch: false,
|
||||||
|
desktop: false,
|
||||||
|
desktopOnly: false,
|
||||||
|
widescreen: false,
|
||||||
|
widescreenOnly: false,
|
||||||
|
fullhd: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
isLoading(state) {
|
isLoading(state) {
|
||||||
@ -36,6 +49,10 @@ export default new Vuex.Store({
|
|||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.authChecked = true;
|
state.authChecked = true;
|
||||||
state.user = user;
|
state.user = user;
|
||||||
|
},
|
||||||
|
|
||||||
|
setMediaQuery(state, data) {
|
||||||
|
state.mediaQueries[data.mediaName] = data.value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
107
app/javascript/styles/_responsive_controls.scss
Normal file
107
app/javascript/styles/_responsive_controls.scss
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
@mixin responsive-button-size($size) {
|
||||||
|
&.is-small-#{$size} {
|
||||||
|
@include button-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-medium-#{$size} {
|
||||||
|
@include button-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-large-#{$size} {
|
||||||
|
@include button-large;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin responsive-label-size($size) {
|
||||||
|
&.is-small-#{$size} {
|
||||||
|
font-size: $size-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-medium-#{$size} {
|
||||||
|
font-size: $size-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-large-#{$size} {
|
||||||
|
font-size: $size-large;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin responsive-control-size($size) {
|
||||||
|
&.is-small-#{$size} {
|
||||||
|
@include control-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-medium-#{$size} {
|
||||||
|
@include control-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-large-#{$size} {
|
||||||
|
@include control-large;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@include mobile {
|
||||||
|
@include responsive-button-size("mobile");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
@include responsive-button-size("tablet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include desktop {
|
||||||
|
@include responsive-button-size("desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include widescreen {
|
||||||
|
@include responsive-button-size("widescreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include fullhd {
|
||||||
|
@include responsive-button-size("fullhd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@include mobile {
|
||||||
|
@include responsive-label-size("mobile");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
@include responsive-label-size("tablet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include desktop {
|
||||||
|
@include responsive-label-size("desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include widescreen {
|
||||||
|
@include responsive-label-size("widescreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include fullhd {
|
||||||
|
@include responsive-label-size("fullhd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, .textarea {
|
||||||
|
@include mobile {
|
||||||
|
@include responsive-control-size("mobile");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
@include responsive-control-size("tablet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include desktop {
|
||||||
|
@include responsive-control-size("desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include widescreen {
|
||||||
|
@include responsive-control-size("widescreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@include fullhd {
|
||||||
|
@include responsive-control-size("fullhd");
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,8 @@
|
|||||||
$green: #79A736;
|
$green: #79A736;
|
||||||
$primary: $green;
|
$primary: $green;
|
||||||
|
|
||||||
|
$modal-content-width: 750px;
|
||||||
|
|
||||||
|
|
||||||
// Make all Bulma variables and functions available
|
// Make all Bulma variables and functions available
|
||||||
@import "~bulma/sass/utilities/initial-variables";
|
@import "~bulma/sass/utilities/initial-variables";
|
||||||
|
@ -3,16 +3,24 @@
|
|||||||
@import "~bulma/sass/utilities/_all";
|
@import "~bulma/sass/utilities/_all";
|
||||||
@import "~bulma/sass/base/_all";
|
@import "~bulma/sass/base/_all";
|
||||||
@import "~bulma/sass/components/navbar";
|
@import "~bulma/sass/components/navbar";
|
||||||
|
@import "~bulma/sass/components/level";
|
||||||
|
@import "~bulma/sass/components/message";
|
||||||
@import "~bulma/sass/components/modal";
|
@import "~bulma/sass/components/modal";
|
||||||
@import "~bulma/sass/elements/_all";
|
@import "~bulma/sass/elements/_all";
|
||||||
@import "~bulma/sass/grid/columns";
|
@import "~bulma/sass/grid/columns";
|
||||||
@import "~bulma/sass/layout/section";
|
@import "~bulma/sass/layout/section";
|
||||||
|
|
||||||
|
@import "./responsive_controls";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: $grey-dark;
|
background-color: $grey-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app .container {
|
#app {
|
||||||
padding: 1rem;
|
padding-top: 1rem;
|
||||||
background-color: $background;
|
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: $white;
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
class RecipeIngredient < ApplicationRecord
|
class RecipeIngredient < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :ingredient
|
belongs_to :ingredient, optional: true
|
||||||
belongs_to :recipe, inverse_of: :recipe_ingredients
|
belongs_to :recipe, inverse_of: :recipe_ingredients
|
||||||
|
|
||||||
validates :sort_order, presence: true
|
validates :sort_order, presence: true
|
||||||
|
@ -10,7 +10,7 @@ module ViewModels
|
|||||||
params ||= {}
|
params ||= {}
|
||||||
PARAMS.each do |attr|
|
PARAMS.each do |attr|
|
||||||
setter = "#{attr}="
|
setter = "#{attr}="
|
||||||
if params[attr]
|
if params[attr].present?
|
||||||
self.send(setter, params[attr])
|
self.send(setter, params[attr])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
31
app/views/recipes/show.json.jbuilder
Normal file
31
app/views/recipes/show.json.jbuilder
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
json.extract! @recipe, :id, :name, :rating, :yields, :total_time, :active_time, :created_at, :updated_at, :step_text
|
||||||
|
|
||||||
|
json.rendered_steps MarkdownProcessor.render(@recipe.step_text)
|
||||||
|
|
||||||
|
json.tags @recipe.tag_names
|
||||||
|
|
||||||
|
json.ingredients @recipe.recipe_ingredients do |ri|
|
||||||
|
json.extract! ri, :id, :ingredient_id, :display_name, :name, :quantity, :units, :preparation, :sort_order
|
||||||
|
|
||||||
|
json.ingredient do
|
||||||
|
if ri.ingredient.nil?
|
||||||
|
json.null!
|
||||||
|
else
|
||||||
|
json.extract! ri.ingredient, :id, :name, :density, :notes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json._destroy false
|
||||||
|
end
|
||||||
|
|
||||||
|
json.nutrition_data do
|
||||||
|
json.errors @recipe.nutrition_data.errors
|
||||||
|
|
||||||
|
json.nutrients NutritionData::NUTRIENTS.select { |_, v| v.present? } do |name, label|
|
||||||
|
json.name name
|
||||||
|
json.label label
|
||||||
|
json.value @recipe.nutrition_data.send(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
@ -2,6 +2,9 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :notes
|
resources :notes
|
||||||
resources :recipes do
|
resources :recipes do
|
||||||
|
collection do
|
||||||
|
post :preview_steps
|
||||||
|
end
|
||||||
resources :logs, only: [:new, :create]
|
resources :logs, only: [:new, :create]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ development:
|
|||||||
host: localhost
|
host: localhost
|
||||||
port: 3035
|
port: 3035
|
||||||
public: localhost:3035
|
public: localhost:3035
|
||||||
hmr: false
|
hmr: true
|
||||||
# Inline should be set to true if using HMR
|
# Inline should be set to true if using HMR
|
||||||
inline: true
|
inline: true
|
||||||
overlay: true
|
overlay: true
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rails/webpacker": "^3.4.1",
|
"@rails/webpacker": "^3.4.1",
|
||||||
|
"autosize": "^4.0.1",
|
||||||
"bulma": "^0.6.2",
|
"bulma": "^0.6.2",
|
||||||
"caniuse-lite": "^1.0.30000815",
|
"caniuse-lite": "^1.0.30000815",
|
||||||
"css-loader": "^0.28.11",
|
"css-loader": "^0.28.11",
|
||||||
|
"lodash": "^4.17.5",
|
||||||
"open-iconic": "^1.1.1",
|
"open-iconic": "^1.1.1",
|
||||||
"svg-loader": "^0.0.2",
|
"svg-loader": "^0.0.2",
|
||||||
"vue": "^2.5.16",
|
"vue": "^2.5.16",
|
||||||
"vue-loader": "^14.2.2",
|
"vue-loader": "^14.2.2",
|
||||||
|
"vue-progressbar": "^0.7.4",
|
||||||
"vue-router": "^3.0.1",
|
"vue-router": "^3.0.1",
|
||||||
"vue-template-compiler": "^2.5.16",
|
"vue-template-compiler": "^2.5.16",
|
||||||
"vuex": "^3.0.1",
|
"vuex": "^3.0.1",
|
||||||
|
10
yarn.lock
10
yarn.lock
@ -280,6 +280,10 @@ autoprefixer@^7.1.1:
|
|||||||
postcss "^6.0.17"
|
postcss "^6.0.17"
|
||||||
postcss-value-parser "^3.2.3"
|
postcss-value-parser "^3.2.3"
|
||||||
|
|
||||||
|
autosize@^4.0.1:
|
||||||
|
version "4.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.1.tgz#4e2f89d00e290dd98e1f95555a8ccb91e9c7a41a"
|
||||||
|
|
||||||
aws-sign2@~0.6.0:
|
aws-sign2@~0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
|
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
|
||||||
@ -3274,7 +3278,7 @@ lodash.uniq@^4.5.0:
|
|||||||
version "4.5.0"
|
version "4.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
||||||
|
|
||||||
"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@~4.17.4:
|
"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.4:
|
||||||
version "4.17.5"
|
version "4.17.5"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
|
||||||
|
|
||||||
@ -5873,6 +5877,10 @@ vue-loader@^14.2.2:
|
|||||||
vue-style-loader "^4.0.1"
|
vue-style-loader "^4.0.1"
|
||||||
vue-template-es2015-compiler "^1.6.0"
|
vue-template-es2015-compiler "^1.6.0"
|
||||||
|
|
||||||
|
vue-progressbar@^0.7.4:
|
||||||
|
version "0.7.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-progressbar/-/vue-progressbar-0.7.4.tgz#435dd9cb3707e29a1b18063afed44aeff371e606"
|
||||||
|
|
||||||
vue-router@^3.0.1:
|
vue-router@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
|
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
|
||||||
|
Loading…
Reference in New Issue
Block a user