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.
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
def verified_request?
|
||||
if request.content_type == "application/json"
|
||||
true
|
||||
else
|
||||
super()
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_valid_user
|
||||
unless current_user?
|
||||
flash[:warning] = "You must login"
|
||||
|
@ -6,7 +6,7 @@ class RecipesController < ApplicationController
|
||||
|
||||
# GET /recipes
|
||||
def index
|
||||
@criteria = ViewModels::RecipeCriteria.new(params[:criteria])
|
||||
@criteria = ViewModels::RecipeCriteria.new(criteria_params)
|
||||
@criteria.page = params[:page]
|
||||
@criteria.per = params[:per]
|
||||
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
|
||||
@ -40,26 +40,6 @@ class RecipesController < ApplicationController
|
||||
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
|
||||
|
||||
# POST /recipes
|
||||
@ -68,9 +48,9 @@ class RecipesController < ApplicationController
|
||||
@recipe.user = current_user
|
||||
|
||||
if @recipe.save
|
||||
redirect_to @recipe, notice: 'Recipe was successfully created.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :new
|
||||
render json: @recipe.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@ -78,13 +58,20 @@ class RecipesController < ApplicationController
|
||||
def update
|
||||
ensure_owner(@recipe) do
|
||||
if @recipe.update(recipe_params)
|
||||
redirect_to @recipe, notice: 'Recipe was successfully updated.'
|
||||
render json: { success: true }
|
||||
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
|
||||
|
||||
# POST /recipes/preview_steps
|
||||
def preview_steps
|
||||
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
|
||||
end
|
||||
|
||||
# DELETE /recipes/1
|
||||
def destroy
|
||||
ensure_owner(@recipe) do
|
||||
@ -108,4 +95,8 @@ class RecipesController < ApplicationController
|
||||
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])
|
||||
end
|
||||
|
||||
def criteria_params
|
||||
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
|
||||
end
|
||||
end
|
||||
|
@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<vue-progress-bar></vue-progress-bar>
|
||||
<app-navbar></app-navbar>
|
||||
<section id="app" class="section">
|
||||
<section id="app" class="">
|
||||
<div class="container">
|
||||
<router-view v-if="!hasError"></router-view>
|
||||
<div v-else>
|
||||
@ -20,6 +21,11 @@
|
||||
import api from "../lib/Api";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
api: api
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
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) {
|
||||
this.loadResource(api.getCurrentUser(), user => {
|
||||
this.setUser(user);
|
||||
})
|
||||
this.loadResource(api.getCurrentUser().then(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>
|
||||
|
||||
import X from "open-iconic/svg/x";
|
||||
import Person from "open-iconic/svg/person";
|
||||
import CaretBottom from "open-iconic/svg/caret-bottom";
|
||||
import CaretTop from "open-iconic/svg/caret-top";
|
||||
import LockLocked from "open-iconic/svg/lock-locked";
|
||||
import LockUnlocked from "open-iconic/svg/lock-unlocked";
|
||||
import Person from "open-iconic/svg/person";
|
||||
import X from "open-iconic/svg/x";
|
||||
|
||||
const iconMap = {
|
||||
x: X,
|
||||
person: Person,
|
||||
'caret-bottom': CaretBottom,
|
||||
'caret-top': CaretTop,
|
||||
'lock-locked': LockLocked,
|
||||
'lock-unlocked': LockUnlocked
|
||||
'lock-unlocked': LockUnlocked,
|
||||
person: Person,
|
||||
x: X
|
||||
};
|
||||
|
||||
const sizeMap = {
|
||||
|
@ -1,14 +1,156 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bulk-input {
|
||||
height: 100%;
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showNutrition: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
</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>
|
||||
<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">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -23,20 +60,24 @@
|
||||
<script>
|
||||
|
||||
import api from "../lib/Api";
|
||||
import debounce from "lodash/debounce";
|
||||
import AppIcon from "./AppIcon";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
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: {
|
||||
recipes() {
|
||||
if (this.recipeData) {
|
||||
@ -44,7 +85,50 @@
|
||||
} else {
|
||||
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() {
|
||||
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) {
|
||||
this.setUser(data.user);
|
||||
this.showLogin = false;
|
||||
} else {
|
||||
this.error = data.message;
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -16,6 +16,8 @@ class Api {
|
||||
return response;
|
||||
} else if (response.status === 404) {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
get(url, params = {}) {
|
||||
|
||||
const queryParams = [];
|
||||
|
||||
for (let key in params) {
|
||||
const val = params[key];
|
||||
objectToUrlParams(obj, queryParams = [], prefixes = []) {
|
||||
for (let key in obj) {
|
||||
const val = obj[key];
|
||||
const paramName = prefixes.join("[") + "[".repeat(Math.min(prefixes.length, 1)) + encodeURIComponent(key) + "]".repeat(prefixes.length);
|
||||
if (Array.isArray(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 {
|
||||
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) {
|
||||
url = url + "?" + queryParams.join("&");
|
||||
}
|
||||
@ -71,19 +80,77 @@ class Api {
|
||||
return this.performRequest(url, "POST", params);
|
||||
}
|
||||
|
||||
patch(url, params = {}) {
|
||||
return this.performRequest(url, "PATCH", params);
|
||||
}
|
||||
|
||||
getRecipeList(page, per, sortColumn, sortDirection, name, tags) {
|
||||
const params = {
|
||||
page: page || null,
|
||||
per: per || null,
|
||||
sort_column: sortColumn || null,
|
||||
sort_direction: sortDirection || null,
|
||||
name: name || null,
|
||||
tags: tags || null
|
||||
criteria: {
|
||||
page: page || null,
|
||||
per: per || null,
|
||||
sort_column: sortColumn || null,
|
||||
sort_direction: sortDirection || null,
|
||||
name: name || null,
|
||||
tags: tags || null
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
const params = {
|
||||
username: username,
|
||||
|
@ -23,6 +23,7 @@ ApiServerError.prototype = Object.assign(new ApiError(), {
|
||||
name: "ApiServerError"
|
||||
});
|
||||
|
||||
|
||||
export function ApiNotFoundError(message, response) {
|
||||
this.message = (message || "Unknown API Server Error Occurred");
|
||||
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) {
|
||||
return (err) => {
|
||||
if (err instanceof errorType) {
|
||||
|
@ -19,11 +19,10 @@ Vue.mixin({
|
||||
'setLoading'
|
||||
]),
|
||||
|
||||
loadResource(promise, successFunc) {
|
||||
loadResource(promise) {
|
||||
this.setLoading(true);
|
||||
|
||||
return promise
|
||||
.then(successFunc)
|
||||
.catch(err => this.setError(err))
|
||||
.then(() => this.setLoading(false));
|
||||
}
|
||||
|
@ -2,13 +2,27 @@ import '../styles';
|
||||
|
||||
import Vue from 'vue'
|
||||
import { sync } from 'vuex-router-sync';
|
||||
import VueProgressBar from "vue-progressbar";
|
||||
import config from '../config';
|
||||
import store from '../store';
|
||||
import router from '../router';
|
||||
import '../lib/GlobalMixins';
|
||||
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);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
|
||||
import RecipeEdit from './components/RecipeEdit';
|
||||
import RecipeShow from './components/RecipeShow';
|
||||
import The404Page from './components/The404Page';
|
||||
import TheAboutPage from './components/TheAboutPage';
|
||||
import TheCalculator from './components/TheCalculator';
|
||||
import TheIngredientList from './components/TheIngredientList';
|
||||
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';
|
||||
|
||||
Vue.use(Router);
|
||||
@ -20,18 +21,27 @@ router.addRoutes(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/recipes'
|
||||
},
|
||||
{
|
||||
path: '/recipes',
|
||||
name: 'recipeList',
|
||||
component: TheRecipeList
|
||||
},
|
||||
{
|
||||
path: '/recipes/new',
|
||||
name: 'new_recipe',
|
||||
component: TheRecipeCreator
|
||||
},
|
||||
{
|
||||
path: '/recipes/:id/edit',
|
||||
name: 'edit_recipe',
|
||||
component: RecipeEdit
|
||||
component: TheRecipeEditor
|
||||
},
|
||||
{
|
||||
path: '/recipe/:id',
|
||||
name: 'recipe',
|
||||
component: RecipeShow
|
||||
component: TheRecipe
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
|
@ -11,7 +11,20 @@ export default new Vuex.Store({
|
||||
loading: false,
|
||||
error: null,
|
||||
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: {
|
||||
isLoading(state) {
|
||||
@ -36,6 +49,10 @@ export default new Vuex.Store({
|
||||
setUser(state, user) {
|
||||
state.authChecked = true;
|
||||
state.user = user;
|
||||
},
|
||||
|
||||
setMediaQuery(state, data) {
|
||||
state.mediaQueries[data.mediaName] = data.value;
|
||||
}
|
||||
},
|
||||
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;
|
||||
$primary: $green;
|
||||
|
||||
$modal-content-width: 750px;
|
||||
|
||||
|
||||
// Make all Bulma variables and functions available
|
||||
@import "~bulma/sass/utilities/initial-variables";
|
||||
|
@ -3,16 +3,24 @@
|
||||
@import "~bulma/sass/utilities/_all";
|
||||
@import "~bulma/sass/base/_all";
|
||||
@import "~bulma/sass/components/navbar";
|
||||
@import "~bulma/sass/components/level";
|
||||
@import "~bulma/sass/components/message";
|
||||
@import "~bulma/sass/components/modal";
|
||||
@import "~bulma/sass/elements/_all";
|
||||
@import "~bulma/sass/grid/columns";
|
||||
@import "~bulma/sass/layout/section";
|
||||
|
||||
@import "./responsive_controls";
|
||||
|
||||
body {
|
||||
background-color: $grey-dark;
|
||||
}
|
||||
|
||||
#app .container {
|
||||
padding: 1rem;
|
||||
background-color: $background;
|
||||
#app {
|
||||
padding-top: 1rem;
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
class RecipeIngredient < ApplicationRecord
|
||||
|
||||
belongs_to :ingredient
|
||||
belongs_to :ingredient, optional: true
|
||||
belongs_to :recipe, inverse_of: :recipe_ingredients
|
||||
|
||||
validates :sort_order, presence: true
|
||||
|
@ -10,7 +10,7 @@ module ViewModels
|
||||
params ||= {}
|
||||
PARAMS.each do |attr|
|
||||
setter = "#{attr}="
|
||||
if params[attr]
|
||||
if params[attr].present?
|
||||
self.send(setter, params[attr])
|
||||
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 :recipes do
|
||||
collection do
|
||||
post :preview_steps
|
||||
end
|
||||
resources :logs, only: [:new, :create]
|
||||
end
|
||||
|
||||
|
@ -38,7 +38,7 @@ development:
|
||||
host: localhost
|
||||
port: 3035
|
||||
public: localhost:3035
|
||||
hmr: false
|
||||
hmr: true
|
||||
# Inline should be set to true if using HMR
|
||||
inline: true
|
||||
overlay: true
|
||||
|
@ -1,13 +1,16 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@rails/webpacker": "^3.4.1",
|
||||
"autosize": "^4.0.1",
|
||||
"bulma": "^0.6.2",
|
||||
"caniuse-lite": "^1.0.30000815",
|
||||
"css-loader": "^0.28.11",
|
||||
"lodash": "^4.17.5",
|
||||
"open-iconic": "^1.1.1",
|
||||
"svg-loader": "^0.0.2",
|
||||
"vue": "^2.5.16",
|
||||
"vue-loader": "^14.2.2",
|
||||
"vue-progressbar": "^0.7.4",
|
||||
"vue-router": "^3.0.1",
|
||||
"vue-template-compiler": "^2.5.16",
|
||||
"vuex": "^3.0.1",
|
||||
|
10
yarn.lock
10
yarn.lock
@ -280,6 +280,10 @@ autoprefixer@^7.1.1:
|
||||
postcss "^6.0.17"
|
||||
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:
|
||||
version "0.6.0"
|
||||
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"
|
||||
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"
|
||||
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-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:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
|
||||
|
Loading…
Reference in New Issue
Block a user