Merge branch 'master' into production
This commit is contained in:
commit
4c9ef33f20
18
.babelrc
Normal file
18
.babelrc
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
["env", {
|
||||||
|
"modules": false,
|
||||||
|
"targets": {
|
||||||
|
"browsers": "> 1%",
|
||||||
|
"uglify": true
|
||||||
|
},
|
||||||
|
"useBuiltIns": true
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
|
||||||
|
"plugins": [
|
||||||
|
"syntax-dynamic-import",
|
||||||
|
"transform-object-rest-spread",
|
||||||
|
["transform-class-properties", { "spec": true }]
|
||||||
|
]
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
.git/
|
.git/
|
||||||
logs/*.*
|
log/*.*
|
||||||
db/*.sqlite*
|
db/*.sqlite*
|
||||||
tmp/*.*
|
tmp/*.*
|
||||||
public/assets
|
public/assets
|
||||||
|
public/packs
|
||||||
|
node_modules/
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,3 +22,8 @@
|
|||||||
/public/assets
|
/public/assets
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/public/packs
|
||||||
|
/public/packs-test
|
||||||
|
/node_modules
|
||||||
|
yarn-debug.log*
|
||||||
|
.yarn-integrity
|
||||||
|
7
.postcssrc.yml
Normal file
7
.postcssrc.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
plugins:
|
||||||
|
postcss-import: {}
|
||||||
|
postcss-cssnext: {
|
||||||
|
features: {
|
||||||
|
customProperties: false
|
||||||
|
}
|
||||||
|
}
|
@ -1 +1 @@
|
|||||||
2.4.3
|
2.4.4
|
37
Dockerfile
37
Dockerfile
@ -3,6 +3,16 @@ FROM phusion/passenger-ruby24:latest
|
|||||||
# Use baseimage-docker's init process.
|
# Use baseimage-docker's init process.
|
||||||
CMD ["/sbin/my_init"]
|
CMD ["/sbin/my_init"]
|
||||||
|
|
||||||
|
# Install Node
|
||||||
|
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \
|
||||||
|
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
|
||||||
|
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends nodejs yarn && \
|
||||||
|
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN gem update --system && gem install bundler
|
||||||
|
|
||||||
# Enable Nginx / Passenger
|
# Enable Nginx / Passenger
|
||||||
RUN rm -f /etc/service/nginx/down
|
RUN rm -f /etc/service/nginx/down
|
||||||
|
|
||||||
@ -21,25 +31,20 @@ ENV RAILS_ENV docker
|
|||||||
ENV PASSENGER_APP_ENV docker
|
ENV PASSENGER_APP_ENV docker
|
||||||
|
|
||||||
# Setup directory and install gems
|
# Setup directory and install gems
|
||||||
RUN mkdir -p /home/app/parsley/
|
RUN mkdir -p /home/app/parsley/log /home/app/parsley/tmp
|
||||||
COPY Gemfile /home/app/parsley/
|
RUN chown -R app:app /home/app/parsley/
|
||||||
COPY Gemfile.lock /home/app/parsley/
|
|
||||||
RUN gem install bundler
|
|
||||||
RUN cd /home/app/parsley/ && bundle install --jobs 4
|
|
||||||
|
|
||||||
# Copy the app into the image
|
|
||||||
COPY . /home/app/parsley/
|
|
||||||
WORKDIR /home/app/parsley/
|
WORKDIR /home/app/parsley/
|
||||||
|
|
||||||
# Set log permissions
|
COPY Gemfile* ./
|
||||||
RUN mkdir -p /home/app/parsley/log
|
RUN bundle install --deployment --jobs 4 --without development test
|
||||||
RUN chmod 0777 /home/app/parsley/log
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --production=true
|
||||||
|
|
||||||
|
# Copy the app into the image
|
||||||
|
COPY --chown="app" . .
|
||||||
|
|
||||||
# Compile assets
|
# Compile assets
|
||||||
RUN env RAILS_ENV=production bundle exec rake assets:clobber assets:precompile
|
RUN su app -c "bundle exec rails webpacker:clobber webpacker:compile"
|
||||||
|
|
||||||
# Set ownership of the tmp folder
|
|
||||||
RUN chown -R app:app /home/app/parsley/tmp
|
|
||||||
|
|
||||||
# Clean up APT when done.
|
|
||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
35
Gemfile
35
Gemfile
@ -1,26 +1,22 @@
|
|||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
gem 'rails', '5.0.6'
|
gem 'rails', '5.2.0'
|
||||||
gem 'sqlite3'
|
gem 'pg', '~> 1.0.0'
|
||||||
gem 'pg', '~> 0.21.0'
|
|
||||||
gem 'sass-rails', '~> 5.0'
|
|
||||||
gem 'uglifier', '>= 1.3.0'
|
|
||||||
|
|
||||||
gem 'puma'
|
gem 'webpacker', '3.5.3'
|
||||||
|
gem 'bootsnap', '>= 1.1.0', require: false
|
||||||
|
|
||||||
# See https://github.com/rails/execjs#readme for more supported runtimes
|
|
||||||
gem 'therubyracer', platforms: :ruby
|
|
||||||
|
|
||||||
# Use jquery as the JavaScript library
|
|
||||||
gem 'jquery-rails', '~> 4.3.1'
|
|
||||||
gem 'bootstrap-sass', '~> 3.3.7'
|
|
||||||
gem 'kaminari', '~> 1.1.1'
|
|
||||||
gem 'turbolinks', '~> 5.1.0'
|
|
||||||
gem 'jbuilder', '~> 2.7'
|
gem 'jbuilder', '~> 2.7'
|
||||||
gem 'cocoon', '~> 1.2.9'
|
#gem 'jbuilder', git: 'https://github.com/rails/jbuilder', branch: 'master'
|
||||||
|
|
||||||
|
gem 'oj', '~> 3.6.2'
|
||||||
|
|
||||||
|
gem 'kaminari', '~> 1.1.1'
|
||||||
gem 'unitwise', '~> 2.2.0'
|
gem 'unitwise', '~> 2.2.0'
|
||||||
gem 'redcarpet', '~> 3.4.0'
|
gem 'redcarpet', '~> 3.4.0'
|
||||||
|
|
||||||
|
gem 'dalli', '~> 2.7.6'
|
||||||
|
|
||||||
# Use ActiveModel has_secure_password
|
# Use ActiveModel has_secure_password
|
||||||
gem 'bcrypt', '~> 3.1.11'
|
gem 'bcrypt', '~> 3.1.11'
|
||||||
|
|
||||||
@ -28,11 +24,14 @@ gem 'tzinfo-data'
|
|||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
|
|
||||||
|
gem 'puma', '~> 3.11'
|
||||||
|
gem 'sqlite3'
|
||||||
|
|
||||||
gem 'guard', '~> 2.14.0'
|
gem 'guard', '~> 2.14.0'
|
||||||
gem 'guard-rspec', require: false
|
gem 'guard-rspec', require: false
|
||||||
gem 'rspec-rails', '~> 3.5.0'
|
gem 'rspec-rails', '~> 3.7.2'
|
||||||
gem 'rails-controller-testing'
|
gem 'rails-controller-testing'
|
||||||
gem 'factory_girl_rails', '~> 4.8.0'
|
gem 'factory_bot_rails', '~> 4.8.2'
|
||||||
gem 'database_cleaner', '~> 1.5.3'
|
gem 'database_cleaner', '~> 1.6.2'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
229
Gemfile.lock
229
Gemfile.lock
@ -1,65 +1,65 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (5.0.6)
|
actioncable (5.2.0)
|
||||||
actionpack (= 5.0.6)
|
actionpack (= 5.2.0)
|
||||||
nio4r (>= 1.2, < 3.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (~> 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailer (5.0.6)
|
actionmailer (5.2.0)
|
||||||
actionpack (= 5.0.6)
|
actionpack (= 5.2.0)
|
||||||
actionview (= 5.0.6)
|
actionview (= 5.2.0)
|
||||||
activejob (= 5.0.6)
|
activejob (= 5.2.0)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (5.0.6)
|
actionpack (5.2.0)
|
||||||
actionview (= 5.0.6)
|
actionview (= 5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
rack (~> 2.0)
|
rack (~> 2.0)
|
||||||
rack-test (~> 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||||
actionview (5.0.6)
|
actionview (5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubis (~> 2.7.0)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.0.3)
|
rails-html-sanitizer (~> 1.0, >= 1.0.3)
|
||||||
activejob (5.0.6)
|
activejob (5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (5.0.6)
|
activemodel (5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
activerecord (5.0.6)
|
activerecord (5.2.0)
|
||||||
activemodel (= 5.0.6)
|
activemodel (= 5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
arel (~> 7.0)
|
arel (>= 9.0)
|
||||||
activesupport (5.0.6)
|
activestorage (5.2.0)
|
||||||
|
actionpack (= 5.2.0)
|
||||||
|
activerecord (= 5.2.0)
|
||||||
|
marcel (~> 0.3.1)
|
||||||
|
activesupport (5.2.0)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (~> 0.7)
|
i18n (>= 0.7, < 2)
|
||||||
minitest (~> 5.1)
|
minitest (~> 5.1)
|
||||||
tzinfo (~> 1.1)
|
tzinfo (~> 1.1)
|
||||||
arel (7.1.4)
|
arel (9.0.0)
|
||||||
autoprefixer-rails (8.1.0.1)
|
bcrypt (3.1.12)
|
||||||
execjs
|
bootsnap (1.3.0)
|
||||||
bcrypt (3.1.11)
|
msgpack (~> 1.0)
|
||||||
bootstrap-sass (3.3.7)
|
|
||||||
autoprefixer-rails (>= 5.2.1)
|
|
||||||
sass (>= 3.3.4)
|
|
||||||
builder (3.2.3)
|
builder (3.2.3)
|
||||||
cocoon (1.2.11)
|
|
||||||
coderay (1.1.2)
|
coderay (1.1.2)
|
||||||
concurrent-ruby (1.0.5)
|
concurrent-ruby (1.0.5)
|
||||||
crass (1.0.3)
|
crass (1.0.4)
|
||||||
database_cleaner (1.5.3)
|
dalli (2.7.8)
|
||||||
|
database_cleaner (1.6.2)
|
||||||
diff-lcs (1.3)
|
diff-lcs (1.3)
|
||||||
erubis (2.7.0)
|
erubi (1.7.1)
|
||||||
execjs (2.7.0)
|
factory_bot (4.8.2)
|
||||||
factory_girl (4.8.1)
|
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
factory_girl_rails (4.8.0)
|
factory_bot_rails (4.8.2)
|
||||||
factory_girl (~> 4.8.0)
|
factory_bot (~> 4.8.2)
|
||||||
railties (>= 3.0.0)
|
railties (>= 3.0.0)
|
||||||
ffi (1.9.23)
|
ffi (1.9.25)
|
||||||
formatador (0.2.5)
|
formatador (0.2.5)
|
||||||
globalid (0.4.1)
|
globalid (0.4.1)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
@ -77,15 +77,11 @@ GEM
|
|||||||
guard (~> 2.1)
|
guard (~> 2.1)
|
||||||
guard-compat (~> 1.1)
|
guard-compat (~> 1.1)
|
||||||
rspec (>= 2.99.0, < 4.0)
|
rspec (>= 2.99.0, < 4.0)
|
||||||
i18n (0.9.5)
|
i18n (1.0.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
jbuilder (2.7.0)
|
jbuilder (2.7.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
multi_json (>= 1.2)
|
multi_json (>= 1.2)
|
||||||
jquery-rails (4.3.1)
|
|
||||||
rails-dom-testing (>= 1, < 3)
|
|
||||||
railties (>= 4.2.0)
|
|
||||||
thor (>= 0.14, < 2.0)
|
|
||||||
kaminari (1.1.1)
|
kaminari (1.1.1)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.1.1)
|
kaminari-actionview (= 1.1.1)
|
||||||
@ -98,52 +94,59 @@ GEM
|
|||||||
activerecord
|
activerecord
|
||||||
kaminari-core (= 1.1.1)
|
kaminari-core (= 1.1.1)
|
||||||
kaminari-core (1.1.1)
|
kaminari-core (1.1.1)
|
||||||
libv8 (3.16.14.19)
|
|
||||||
liner (0.2.4)
|
liner (0.2.4)
|
||||||
listen (3.1.5)
|
listen (3.1.5)
|
||||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||||
rb-inotify (~> 0.9, >= 0.9.7)
|
rb-inotify (~> 0.9, >= 0.9.7)
|
||||||
ruby_dep (~> 1.2)
|
ruby_dep (~> 1.2)
|
||||||
loofah (2.2.1)
|
loofah (2.2.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.5.9)
|
nokogiri (>= 1.5.9)
|
||||||
lumberjack (1.0.12)
|
lumberjack (1.0.13)
|
||||||
mail (2.7.0)
|
mail (2.7.0)
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
|
marcel (0.3.2)
|
||||||
|
mimemagic (~> 0.3.2)
|
||||||
memoizable (0.4.2)
|
memoizable (0.4.2)
|
||||||
thread_safe (~> 0.3, >= 0.3.1)
|
thread_safe (~> 0.3, >= 0.3.1)
|
||||||
method_source (0.9.0)
|
method_source (0.9.0)
|
||||||
|
mimemagic (0.3.2)
|
||||||
mini_mime (1.0.0)
|
mini_mime (1.0.0)
|
||||||
mini_portile2 (2.3.0)
|
mini_portile2 (2.3.0)
|
||||||
minitest (5.11.3)
|
minitest (5.11.3)
|
||||||
|
msgpack (1.2.4)
|
||||||
multi_json (1.13.1)
|
multi_json (1.13.1)
|
||||||
nenv (0.3.0)
|
nenv (0.3.0)
|
||||||
nio4r (2.3.0)
|
nio4r (2.3.1)
|
||||||
nokogiri (1.8.2)
|
nokogiri (1.8.2)
|
||||||
mini_portile2 (~> 2.3.0)
|
mini_portile2 (~> 2.3.0)
|
||||||
notiffany (0.1.1)
|
notiffany (0.1.1)
|
||||||
nenv (~> 0.1)
|
nenv (~> 0.1)
|
||||||
shellany (~> 0.0)
|
shellany (~> 0.0)
|
||||||
|
oj (3.6.2)
|
||||||
parslet (1.8.2)
|
parslet (1.8.2)
|
||||||
pg (0.21.0)
|
pg (1.0.0)
|
||||||
pry (0.11.3)
|
pry (0.11.3)
|
||||||
coderay (~> 1.1.0)
|
coderay (~> 1.1.0)
|
||||||
method_source (~> 0.9.0)
|
method_source (~> 0.9.0)
|
||||||
puma (3.11.3)
|
puma (3.11.4)
|
||||||
rack (2.0.4)
|
rack (2.0.5)
|
||||||
rack-test (0.6.3)
|
rack-proxy (0.6.4)
|
||||||
rack (>= 1.0)
|
rack
|
||||||
rails (5.0.6)
|
rack-test (1.0.0)
|
||||||
actioncable (= 5.0.6)
|
rack (>= 1.0, < 3)
|
||||||
actionmailer (= 5.0.6)
|
rails (5.2.0)
|
||||||
actionpack (= 5.0.6)
|
actioncable (= 5.2.0)
|
||||||
actionview (= 5.0.6)
|
actionmailer (= 5.2.0)
|
||||||
activejob (= 5.0.6)
|
actionpack (= 5.2.0)
|
||||||
activemodel (= 5.0.6)
|
actionview (= 5.2.0)
|
||||||
activerecord (= 5.0.6)
|
activejob (= 5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activemodel (= 5.2.0)
|
||||||
|
activerecord (= 5.2.0)
|
||||||
|
activestorage (= 5.2.0)
|
||||||
|
activesupport (= 5.2.0)
|
||||||
bundler (>= 1.3.0)
|
bundler (>= 1.3.0)
|
||||||
railties (= 5.0.6)
|
railties (= 5.2.0)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-controller-testing (1.0.2)
|
rails-controller-testing (1.0.2)
|
||||||
actionpack (~> 5.x, >= 5.0.1)
|
actionpack (~> 5.x, >= 5.0.1)
|
||||||
@ -152,53 +155,41 @@ GEM
|
|||||||
rails-dom-testing (2.0.3)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.0.3)
|
rails-html-sanitizer (1.0.4)
|
||||||
loofah (~> 2.0)
|
loofah (~> 2.2, >= 2.2.2)
|
||||||
railties (5.0.6)
|
railties (5.2.0)
|
||||||
actionpack (= 5.0.6)
|
actionpack (= 5.2.0)
|
||||||
activesupport (= 5.0.6)
|
activesupport (= 5.2.0)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
thor (>= 0.18.1, < 2.0)
|
thor (>= 0.18.1, < 2.0)
|
||||||
rake (12.3.0)
|
rake (12.3.1)
|
||||||
rb-fsevent (0.10.3)
|
rb-fsevent (0.10.3)
|
||||||
rb-inotify (0.9.10)
|
rb-inotify (0.9.10)
|
||||||
ffi (>= 0.5.0, < 2)
|
ffi (>= 0.5.0, < 2)
|
||||||
redcarpet (3.4.0)
|
redcarpet (3.4.0)
|
||||||
ref (2.0.0)
|
rspec (3.7.0)
|
||||||
rspec (3.5.0)
|
rspec-core (~> 3.7.0)
|
||||||
rspec-core (~> 3.5.0)
|
rspec-expectations (~> 3.7.0)
|
||||||
rspec-expectations (~> 3.5.0)
|
rspec-mocks (~> 3.7.0)
|
||||||
rspec-mocks (~> 3.5.0)
|
rspec-core (3.7.1)
|
||||||
rspec-core (3.5.4)
|
rspec-support (~> 3.7.0)
|
||||||
rspec-support (~> 3.5.0)
|
rspec-expectations (3.7.0)
|
||||||
rspec-expectations (3.5.0)
|
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.5.0)
|
rspec-support (~> 3.7.0)
|
||||||
rspec-mocks (3.5.0)
|
rspec-mocks (3.7.0)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.5.0)
|
rspec-support (~> 3.7.0)
|
||||||
rspec-rails (3.5.2)
|
rspec-rails (3.7.2)
|
||||||
actionpack (>= 3.0)
|
actionpack (>= 3.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
railties (>= 3.0)
|
railties (>= 3.0)
|
||||||
rspec-core (~> 3.5.0)
|
rspec-core (~> 3.7.0)
|
||||||
rspec-expectations (~> 3.5.0)
|
rspec-expectations (~> 3.7.0)
|
||||||
rspec-mocks (~> 3.5.0)
|
rspec-mocks (~> 3.7.0)
|
||||||
rspec-support (~> 3.5.0)
|
rspec-support (~> 3.7.0)
|
||||||
rspec-support (3.5.0)
|
rspec-support (3.7.1)
|
||||||
ruby_dep (1.5.0)
|
ruby_dep (1.5.0)
|
||||||
sass (3.5.5)
|
|
||||||
sass-listen (~> 4.0.0)
|
|
||||||
sass-listen (4.0.0)
|
|
||||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
|
||||||
rb-inotify (~> 0.9, >= 0.9.7)
|
|
||||||
sass-rails (5.0.7)
|
|
||||||
railties (>= 4.0.0, < 6)
|
|
||||||
sass (~> 3.1)
|
|
||||||
sprockets (>= 2.8, < 4.0)
|
|
||||||
sprockets-rails (>= 2.0, < 4.0)
|
|
||||||
tilt (>= 1.1, < 3)
|
|
||||||
shellany (0.0.1)
|
shellany (0.0.1)
|
||||||
signed_multiset (0.2.1)
|
signed_multiset (0.2.1)
|
||||||
sprockets (3.7.1)
|
sprockets (3.7.1)
|
||||||
@ -209,27 +200,22 @@ GEM
|
|||||||
activesupport (>= 4.0)
|
activesupport (>= 4.0)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
sqlite3 (1.3.13)
|
sqlite3 (1.3.13)
|
||||||
therubyracer (0.12.3)
|
|
||||||
libv8 (~> 3.16.14.15)
|
|
||||||
ref
|
|
||||||
thor (0.20.0)
|
thor (0.20.0)
|
||||||
thread_safe (0.3.6)
|
thread_safe (0.3.6)
|
||||||
tilt (2.0.8)
|
|
||||||
turbolinks (5.1.0)
|
|
||||||
turbolinks-source (~> 5.1)
|
|
||||||
turbolinks-source (5.1.0)
|
|
||||||
tzinfo (1.2.5)
|
tzinfo (1.2.5)
|
||||||
thread_safe (~> 0.1)
|
thread_safe (~> 0.1)
|
||||||
tzinfo-data (1.2018.3)
|
tzinfo-data (1.2018.5)
|
||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
uglifier (4.1.8)
|
|
||||||
execjs (>= 0.3.0, < 3)
|
|
||||||
unitwise (2.2.0)
|
unitwise (2.2.0)
|
||||||
liner (~> 0.2)
|
liner (~> 0.2)
|
||||||
memoizable (~> 0.4)
|
memoizable (~> 0.4)
|
||||||
parslet (~> 1.5)
|
parslet (~> 1.5)
|
||||||
signed_multiset (~> 0.2)
|
signed_multiset (~> 0.2)
|
||||||
websocket-driver (0.6.5)
|
webpacker (3.5.3)
|
||||||
|
activesupport (>= 4.2)
|
||||||
|
rack-proxy (>= 0.6.1)
|
||||||
|
railties (>= 4.2)
|
||||||
|
websocket-driver (0.7.0)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.3)
|
websocket-extensions (0.1.3)
|
||||||
|
|
||||||
@ -238,28 +224,25 @@ PLATFORMS
|
|||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
bcrypt (~> 3.1.11)
|
bcrypt (~> 3.1.11)
|
||||||
bootstrap-sass (~> 3.3.7)
|
bootsnap (>= 1.1.0)
|
||||||
cocoon (~> 1.2.9)
|
dalli (~> 2.7.6)
|
||||||
database_cleaner (~> 1.5.3)
|
database_cleaner (~> 1.6.2)
|
||||||
factory_girl_rails (~> 4.8.0)
|
factory_bot_rails (~> 4.8.2)
|
||||||
guard (~> 2.14.0)
|
guard (~> 2.14.0)
|
||||||
guard-rspec
|
guard-rspec
|
||||||
jbuilder (~> 2.7)
|
jbuilder (~> 2.7)
|
||||||
jquery-rails (~> 4.3.1)
|
|
||||||
kaminari (~> 1.1.1)
|
kaminari (~> 1.1.1)
|
||||||
pg (~> 0.21.0)
|
oj (~> 3.6.2)
|
||||||
puma
|
pg (~> 1.0.0)
|
||||||
rails (= 5.0.6)
|
puma (~> 3.11)
|
||||||
|
rails (= 5.2.0)
|
||||||
rails-controller-testing
|
rails-controller-testing
|
||||||
redcarpet (~> 3.4.0)
|
redcarpet (~> 3.4.0)
|
||||||
rspec-rails (~> 3.5.0)
|
rspec-rails (~> 3.7.2)
|
||||||
sass-rails (~> 5.0)
|
|
||||||
sqlite3
|
sqlite3
|
||||||
therubyracer
|
|
||||||
turbolinks (~> 5.1.0)
|
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
uglifier (>= 1.3.0)
|
|
||||||
unitwise (~> 2.2.0)
|
unitwise (~> 2.2.0)
|
||||||
|
webpacker (= 3.5.3)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
1.16.1
|
1.16.1
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2016 Dan Elbert
|
Copyright (c) 2018 Dan Elbert
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
@ -5,7 +5,7 @@ A self hosted cookbook
|
|||||||
|
|
||||||
Parsley is released under the MIT License.
|
Parsley is released under the MIT License.
|
||||||
|
|
||||||
Copyright (C) 2016 Dan Elbert
|
Copyright (C) 2018 Dan Elbert
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
@ -10,24 +10,6 @@
|
|||||||
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
|
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
|
||||||
// about supported directives.
|
// about supported directives.
|
||||||
//
|
//
|
||||||
//= require jquery
|
|
||||||
//= require jquery_ujs
|
|
||||||
//= require turbolinks
|
|
||||||
//= require bootstrap-sprockets
|
|
||||||
//= require bootstrap-datepicker
|
|
||||||
//= require bootstrap-tagsinput
|
|
||||||
//= require cocoon
|
|
||||||
//= require typeahead
|
|
||||||
//= require autosize
|
|
||||||
//= require chosen.jquery
|
|
||||||
//= require codemirror
|
|
||||||
//= require markdown
|
|
||||||
//= require underscore
|
|
||||||
//= require_tree .
|
//= require_tree .
|
||||||
|
|
||||||
// Setup star rating automagic
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
|
|
||||||
$("input[data-rating='true']").starRating();
|
|
||||||
|
|
||||||
});
|
|
@ -1,124 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
function State() {
|
|
||||||
this.input = null;
|
|
||||||
this.outputUnit = null;
|
|
||||||
this.density = null;
|
|
||||||
this.changed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
State.prototype.setInput = function(value) {
|
|
||||||
if (value != this.input) {
|
|
||||||
this.changed = true;
|
|
||||||
this.input = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
State.prototype.setDensity = function(value) {
|
|
||||||
if (value != this.density) {
|
|
||||||
this.changed = true;
|
|
||||||
this.density = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
State.prototype.setOutputUnit = function(value) {
|
|
||||||
if (value != this.outputUnit) {
|
|
||||||
this.changed = true;
|
|
||||||
this.outputUnit = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
State.prototype.is_changed = function() {
|
|
||||||
return this.changed;
|
|
||||||
};
|
|
||||||
|
|
||||||
State.prototype.reset = function() {
|
|
||||||
this.changed = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
var ingredientSearchEngine = new Bloodhound({
|
|
||||||
initialize: false,
|
|
||||||
datumTokenizer: function(datum) {
|
|
||||||
return Bloodhound.tokenizers.whitespace(datum.name);
|
|
||||||
},
|
|
||||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
|
||||||
identify: function(datum) { return datum.id; },
|
|
||||||
sorter: function(a, b) {
|
|
||||||
if (a.name < b.name) {
|
|
||||||
return -1;
|
|
||||||
} else if (b.name < a.name) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remote: {
|
|
||||||
url: '/calculator/ingredient_search.json?query=%QUERY',
|
|
||||||
wildcard: '%QUERY'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
|
|
||||||
var state = new State();
|
|
||||||
var $input = $("#input");
|
|
||||||
var $output = $("#output");
|
|
||||||
var $density = $("#density");
|
|
||||||
var $outputUnit = $("#output_unit");
|
|
||||||
|
|
||||||
var performUpdate = _.debounce(function() {
|
|
||||||
$.getJSON(
|
|
||||||
"/calculator/calculate",
|
|
||||||
{input: $input.val(), output_unit: $outputUnit.val(), density: $density.val()},
|
|
||||||
function(data) {
|
|
||||||
if (data.errors.length) {
|
|
||||||
$("#errors_panel").show();
|
|
||||||
$("#errors_container").html(data.errors.join(" "));
|
|
||||||
} else {
|
|
||||||
$("#errors_panel").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
$output.val(data.output);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
var ingredientPicked = function(i) {
|
|
||||||
$density.val(i.density);
|
|
||||||
$density.trigger('change');
|
|
||||||
};
|
|
||||||
|
|
||||||
$input.add($outputUnit).add($density).on('change blur keyup', function(evt) {
|
|
||||||
state.setInput($input.val());
|
|
||||||
state.setOutputUnit($outputUnit.val());
|
|
||||||
state.setDensity($density.val());
|
|
||||||
|
|
||||||
if (state.is_changed()) {
|
|
||||||
performUpdate();
|
|
||||||
state.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($("#calculator").length) {
|
|
||||||
ingredientSearchEngine.initialize(false);
|
|
||||||
|
|
||||||
$("#ingredient").typeahead({},
|
|
||||||
{
|
|
||||||
name: 'ingredients',
|
|
||||||
source: ingredientSearchEngine,
|
|
||||||
display: function(datum) {
|
|
||||||
return datum.name;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("typeahead:select", function(evt, value) {
|
|
||||||
ingredientPicked(value);
|
|
||||||
})
|
|
||||||
.on("typeahead:autocomplete", function(evt, value) {
|
|
||||||
ingredientPicked(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,36 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
var pluginName = "checkable";
|
|
||||||
|
|
||||||
var defaultOptions = {
|
|
||||||
childrenSelector: "li",
|
|
||||||
selectedClass: "checked"
|
|
||||||
};
|
|
||||||
|
|
||||||
var methods = {
|
|
||||||
initialize: function (opts, sources) {
|
|
||||||
|
|
||||||
return this.each(function() {
|
|
||||||
var options = $.extend({}, defaultOptions, opts);
|
|
||||||
$(this).on("click", options.childrenSelector, function(evt) {
|
|
||||||
$(evt.currentTarget).toggleClass(options.selectedClass);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var privateMethods = {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn[pluginName] = function (method) {
|
|
||||||
if (methods[method]) {
|
|
||||||
return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
|
|
||||||
} else if (typeof method === 'object' || ! method) {
|
|
||||||
return methods.initialize.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
$.error('Method ' + method + ' does not exist on jQuery.' + pluginName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
var flashMessageTypeMap = {
|
|
||||||
error: "danger",
|
|
||||||
notice: "success"
|
|
||||||
};
|
|
||||||
|
|
||||||
function flashMessage(flashType, message) {
|
|
||||||
|
|
||||||
var timeoutIdContainer = {};
|
|
||||||
|
|
||||||
if (flashMessageTypeMap[flashType]) {
|
|
||||||
flashType = flashMessageTypeMap[flashType];
|
|
||||||
}
|
|
||||||
|
|
||||||
var closeButton = $("<button type='button' />")
|
|
||||||
.addClass("close")
|
|
||||||
.append($("<span />").html("×"))
|
|
||||||
.bind("click.Flash", function() { $(this).parent().hide({effect: "fade", duration: 1000}); clearTimeout(timeoutIdContainer.id); });
|
|
||||||
|
|
||||||
var $flashDiv = $("<div></div>")
|
|
||||||
.html(message)
|
|
||||||
.append(closeButton)
|
|
||||||
.addClass("popup")
|
|
||||||
.addClass("alert")
|
|
||||||
.addClass("alert-" + flashType)
|
|
||||||
.hide()
|
|
||||||
.appendTo("#flashContainer")
|
|
||||||
.show({effect: "pulsate", times: 1, duration: 1500});
|
|
||||||
|
|
||||||
timeoutIdContainer.id = setTimeout(function() {
|
|
||||||
$flashDiv.unbind(".Flash");
|
|
||||||
$flashDiv.hide({effect: "fade", duration: 1000});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
$("#flashHolder").find("div").each(function(idx, div) {
|
|
||||||
var $div = $(div);
|
|
||||||
var type = $div.attr("class");
|
|
||||||
var message = $div.html();
|
|
||||||
|
|
||||||
$div.remove();
|
|
||||||
|
|
||||||
flashMessage(type, message);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,88 +0,0 @@
|
|||||||
|
|
||||||
window.INGREDIENT_API = {};
|
|
||||||
|
|
||||||
(function($) {
|
|
||||||
|
|
||||||
function initializeEditor($ingredientForm) {
|
|
||||||
usdaFoodSearchEngine.initialize(false);
|
|
||||||
|
|
||||||
var $typeahead = $ingredientForm.find(".ndbn_typeahead");
|
|
||||||
|
|
||||||
$typeahead.typeahead_search({
|
|
||||||
searchUrl: '/ingredients/usda_food_search.html',
|
|
||||||
resultsContainer: $ingredientForm.find(".ndbn_results")
|
|
||||||
},{
|
|
||||||
name: 'usdaFoods',
|
|
||||||
source: usdaFoodSearchEngine,
|
|
||||||
limit: 20,
|
|
||||||
display: function(datum) {
|
|
||||||
return datum.name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$typeahead.on("typeahead_search:selected", function(evt, item) {
|
|
||||||
selectNdbn($ingredientForm, item.ndbn);
|
|
||||||
});
|
|
||||||
|
|
||||||
$ingredientForm.on("click", ".ndbn_results .food_result", function(evt) {
|
|
||||||
var $item = $(evt.target);
|
|
||||||
var ndbn = $item.data("ndbn");
|
|
||||||
selectNdbn($ingredientForm, ndbn);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNdbn($ingredientForm, ndbn) {
|
|
||||||
var id = $ingredientForm.find("input.id").val();
|
|
||||||
|
|
||||||
$ingredientForm.find("input.ndbn").val(ndbn);
|
|
||||||
$ingredientForm.attr("action", "/ingredients/" + id + "/select_ndbn").attr("data-remote", "true");
|
|
||||||
|
|
||||||
$ingredientForm.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function customTokenizer(str) {
|
|
||||||
if (str) {
|
|
||||||
var cleaned = str.replace(/,/g, "");
|
|
||||||
return Bloodhound.tokenizers.whitespace(cleaned);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.INGREDIENT_API.initialize = function() {
|
|
||||||
var $ingredientForm = $("#ingredient_form");
|
|
||||||
|
|
||||||
if ($ingredientForm.length) {
|
|
||||||
initializeEditor($ingredientForm);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var usdaFoodSearchEngine = new Bloodhound({
|
|
||||||
initialize: false,
|
|
||||||
datumTokenizer: function(datum) {
|
|
||||||
var str = datum ? datum.name : null;
|
|
||||||
return customTokenizer(str);
|
|
||||||
},
|
|
||||||
queryTokenizer: customTokenizer,
|
|
||||||
identify: function(datum) { return datum.ndbn; },
|
|
||||||
sorter: function(a, b) {
|
|
||||||
if (a.name < b.name) {
|
|
||||||
return -1;
|
|
||||||
} else if (b.name < a.name) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remote: {
|
|
||||||
url: '/ingredients/usda_food_search.json?query=%QUERY',
|
|
||||||
wildcard: '%QUERY'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
window.INGREDIENT_API.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,7 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
$(".log-form input.datepicker").datepicker({autoclose: true, todayBtn: "linked", format: "yyyy-mm-dd"});
|
|
||||||
});
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,393 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
var ingredientSearchEngine = new Bloodhound({
|
|
||||||
initialize: false,
|
|
||||||
datumTokenizer: function(datum) {
|
|
||||||
return Bloodhound.tokenizers.whitespace(datum.name);
|
|
||||||
},
|
|
||||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
|
||||||
identify: function(datum) { return datum.id; },
|
|
||||||
sorter: function(a, b) {
|
|
||||||
if (a.name < b.name) {
|
|
||||||
return -1;
|
|
||||||
} else if (b.name < a.name) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
prefetch: {
|
|
||||||
url: '/ingredients/prefetch.json',
|
|
||||||
cache: false
|
|
||||||
},
|
|
||||||
remote: {
|
|
||||||
url: '/ingredients/search.json?query=%QUERY',
|
|
||||||
wildcard: '%QUERY'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// var tagSearchEngine = new Bloodhound({
|
|
||||||
// initialize: false,
|
|
||||||
// datumTokenizer: function(datum) {
|
|
||||||
// return Bloodhound.tokenizers.whitespace(datum.name);
|
|
||||||
// },
|
|
||||||
// queryTokenizer: Bloodhound.tokenizers.whitespace,
|
|
||||||
// identify: function(datum) { return datum.id; },
|
|
||||||
// sorter: function(a, b) {
|
|
||||||
// if (a.name < b.name) {
|
|
||||||
// return -1;
|
|
||||||
// } else if (b.name < a.name) {
|
|
||||||
// return 1;
|
|
||||||
// } else {
|
|
||||||
// return 0;
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// prefetch: {
|
|
||||||
// url: '/tags/prefetch.json',
|
|
||||||
// cache: false
|
|
||||||
// },
|
|
||||||
// remote: {
|
|
||||||
// url: '/tags/search.json?query=%QUERY',
|
|
||||||
// wildcard: '%QUERY'
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
function reorder($container) {
|
|
||||||
$container.find("div.nested-fields").each(function(idx, editor) {
|
|
||||||
var $editor = $(editor);
|
|
||||||
$editor.find('input.sort_order').val(idx + 1).trigger("changed");
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function initializeIngredientEditor($container, ingredientSearchEngine) {
|
|
||||||
// $container is either an element that contains many editors, or a single editor.
|
|
||||||
var $editors = $container.find(".ingredient-typeahead").closest(".nested-fields");
|
|
||||||
|
|
||||||
$editors.each(function(idx, elem) {
|
|
||||||
var $editor = $(elem);
|
|
||||||
var $ingredientId = $editor.find("input.ingredient_id");
|
|
||||||
var $group = $editor.find("div.typeahead-group");
|
|
||||||
|
|
||||||
$editor.find(".ingredient-typeahead").typeahead({},
|
|
||||||
{
|
|
||||||
name: 'ingredients',
|
|
||||||
source: ingredientSearchEngine,
|
|
||||||
display: function(datum) {
|
|
||||||
return datum.name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($ingredientId.val().length) {
|
|
||||||
$group.addClass("has-success");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingredientItemPicked($typeahead, datum) {
|
|
||||||
var $container = $typeahead.closest(".nested-fields");
|
|
||||||
var $ingredientId = $container.find("input.ingredient_id");
|
|
||||||
var $group = $container.find("div.typeahead-group");
|
|
||||||
|
|
||||||
$ingredientId.val(datum.id);
|
|
||||||
$typeahead.typeahead('val', datum.name);
|
|
||||||
$group.addClass("has-success");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingredientNameChange($typeahead, ingredientSearchEngine) {
|
|
||||||
var $container = $typeahead.closest(".nested-fields");
|
|
||||||
var $ingredientId = $container.find("input.ingredient_id");
|
|
||||||
var $group = $container.find("div.typeahead-group");
|
|
||||||
|
|
||||||
var id = $ingredientId.val();
|
|
||||||
var value = $typeahead.typeahead('val');
|
|
||||||
|
|
||||||
if (id && id.length) {
|
|
||||||
var found = ingredientSearchEngine.get([id]);
|
|
||||||
if (found && found[0] && found[0].name != value) {
|
|
||||||
// User has chosen something custom
|
|
||||||
$ingredientId.val('');
|
|
||||||
|
|
||||||
$group.removeClass("has-success");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addIngredient(item) {
|
|
||||||
$("#ingredient-list").one("cocoon:before-insert", function(e, $container) {
|
|
||||||
var $ingredientId = $container.find("input.ingredient_id");
|
|
||||||
var $name = $container.find("input.ingredient-typeahead");
|
|
||||||
var $quantity = $container.find("input.quantity");
|
|
||||||
var $units = $container.find("input.units");
|
|
||||||
var $preparation = $container.find("input.preparation");
|
|
||||||
|
|
||||||
$name.val(item.name);
|
|
||||||
$ingredientId.val(item.ingredient_id);
|
|
||||||
$units.val(item.units);
|
|
||||||
$quantity.val(item.quantity);
|
|
||||||
$preparation.val(item.preparation);
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#addIngredientButton").trigger("click");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIngredients() {
|
|
||||||
var data = [];
|
|
||||||
$("#ingredient-list .ingredient-editor").each(function() {
|
|
||||||
var $container = $(this);
|
|
||||||
|
|
||||||
var $ingredientId = $container.find("input.ingredient_id");
|
|
||||||
var $name = $container.find("input.ingredient-typeahead.tt-input");
|
|
||||||
var $quantity = $container.find("input.quantity");
|
|
||||||
var $units = $container.find("input.units");
|
|
||||||
var $preparation = $container.find("input.preparation");
|
|
||||||
|
|
||||||
data.push({ingredient_id: $ingredientId.val(), name: $name.typeahead("val"), quantity: $quantity.val(), units: $units.val(), preparation: $preparation.val()});
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
|
|
||||||
var $ingredientList = $("#ingredient-list");
|
|
||||||
var $tagInput = $("select.tag_names");
|
|
||||||
var $stepInput = $("textarea#recipe_step_text");
|
|
||||||
|
|
||||||
if ($ingredientList.length) {
|
|
||||||
ingredientSearchEngine.initialize(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($stepInput.length) {
|
|
||||||
CodeMirror.fromTextArea(
|
|
||||||
$stepInput[0],
|
|
||||||
{
|
|
||||||
mode: {
|
|
||||||
name: 'markdown',
|
|
||||||
strikethrough: true
|
|
||||||
},
|
|
||||||
// config tomfoolery to enable soft tabs
|
|
||||||
extraKeys: {
|
|
||||||
Tab: function(cm) {
|
|
||||||
if (cm.somethingSelected()) {
|
|
||||||
cm.indentSelection("add");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cm.options.indentWithTabs)
|
|
||||||
cm.replaceSelection("\t", "end", "+input");
|
|
||||||
else
|
|
||||||
cm.execCommand("insertSoftTab");
|
|
||||||
},
|
|
||||||
"Shift-Tab": function(cm) {
|
|
||||||
cm.indentSelection("subtract");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
indentUnit: 2,
|
|
||||||
tabSize: 2,
|
|
||||||
indentWithTabs: false,
|
|
||||||
lineWrapping: true,
|
|
||||||
lineNumbers: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tagInput.tagsinput({
|
|
||||||
trimValue: true,
|
|
||||||
confirmKeys: [9, 13, 32, 44] // tab, enter, space, comma
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
initializeIngredientEditor($ingredientList, ingredientSearchEngine);
|
|
||||||
|
|
||||||
$ingredientList
|
|
||||||
.on("cocoon:after-insert", function(e, item) {
|
|
||||||
reorder($ingredientList);
|
|
||||||
initializeIngredientEditor(item, ingredientSearchEngine);
|
|
||||||
})
|
|
||||||
.on("cocoon:after-remove", function(e, item) {
|
|
||||||
if (item.find(".remove-button.existing").length) {
|
|
||||||
item.detach().appendTo("#deleted_ingredients");
|
|
||||||
}
|
|
||||||
reorder($ingredientList);
|
|
||||||
})
|
|
||||||
.on("typeahead:change", function(evt, value) {
|
|
||||||
ingredientNameChange($(evt.target), ingredientSearchEngine);
|
|
||||||
})
|
|
||||||
.on("typeahead:select", function(evt, value) {
|
|
||||||
ingredientItemPicked($(evt.target), value);
|
|
||||||
})
|
|
||||||
.on("typeahead:autocomplete", function(evt, value) {
|
|
||||||
ingredientItemPicked($(evt.target), value);
|
|
||||||
})
|
|
||||||
.on("click", "button.ingredient_convert_btn", function(evt) {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#convert_modal')
|
|
||||||
.on('show.bs.modal', function (event) {
|
|
||||||
var $button = $(event.relatedTarget);
|
|
||||||
var $modal = $(this);
|
|
||||||
|
|
||||||
var $editor = $button.closest(".ingredient-editor");
|
|
||||||
|
|
||||||
$modal.data('ingredient-editor', $editor);
|
|
||||||
|
|
||||||
var $quantity = $editor.find("input.quantity");
|
|
||||||
var $units = $editor.find("input.units");
|
|
||||||
var $ingredientId = $editor.find("input.ingredient_id");
|
|
||||||
|
|
||||||
var $modalQuantity = $modal.find("input.quantity");
|
|
||||||
var $modalUnits = $modal.find("input.units");
|
|
||||||
var $modalIngredientId = $modal.find("input.ingredient_id");
|
|
||||||
|
|
||||||
$modalQuantity.val($quantity.val());
|
|
||||||
$modalUnits.val($units.val());
|
|
||||||
$modalIngredientId.val($ingredientId.val());
|
|
||||||
})
|
|
||||||
.on("ajax:success", "form", function(evt, data, status, xhr) {
|
|
||||||
var $modal = $("#convert_modal");
|
|
||||||
var $editor = $modal.data('ingredient-editor');
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
var $quantity = $editor.find("input.quantity");
|
|
||||||
var $units = $editor.find("input.units");
|
|
||||||
|
|
||||||
var $modalOutUnits = $modal.find("input.output_units");
|
|
||||||
|
|
||||||
$quantity.val(data.output_quantity);
|
|
||||||
if ($modalOutUnits.val().length) {
|
|
||||||
$units.val($modalOutUnits.val());
|
|
||||||
}
|
|
||||||
|
|
||||||
$modal.modal('hide');
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#modal_form_container").replaceWith($(data.form_html));
|
|
||||||
});
|
|
||||||
|
|
||||||
var $bulkIngredientsModal = $("#bulk_ingredients_modal");
|
|
||||||
var $ingredientBulkInput = $("#ingredient_bulk_input");
|
|
||||||
var $ingredientBulkList = $("#ingredient_bulk_parsed_list");
|
|
||||||
autosize($ingredientBulkInput);
|
|
||||||
|
|
||||||
var parseBulkIngredients = function() {
|
|
||||||
var data = $ingredientBulkInput.val();
|
|
||||||
$ingredientBulkList.empty();
|
|
||||||
|
|
||||||
var parsed = [];
|
|
||||||
var x;
|
|
||||||
|
|
||||||
var lines = data.replace("\r", "").split("\n");
|
|
||||||
|
|
||||||
var regex = /^(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,]*)(?:,\s*(.*))?$/i;
|
|
||||||
|
|
||||||
var magicFunc = function(str) {
|
|
||||||
if (str == "-") {
|
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (x = 0; x < lines.length; x++) {
|
|
||||||
var line = lines[x].trim();
|
|
||||||
if (line.length == 0) { continue; }
|
|
||||||
|
|
||||||
var barIndex = line.lastIndexOf("|");
|
|
||||||
var afterBar = null;
|
|
||||||
|
|
||||||
if (barIndex >= 0) {
|
|
||||||
afterBar = line.slice(barIndex + 1);
|
|
||||||
line = line.slice(0, barIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
var match = line.match(regex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$bulkIngredientsModal.data("bulkData", parsed);
|
|
||||||
|
|
||||||
for (x = 0; x < parsed.length; x++) {
|
|
||||||
var item = parsed[x];
|
|
||||||
if (item != null) {
|
|
||||||
$ingredientBulkList.append(
|
|
||||||
$("<tr />")
|
|
||||||
.append($("<td />").addClass("quantity").text(item.quantity))
|
|
||||||
.append($("<td />").addClass("units").text(item.units))
|
|
||||||
.append($("<td />").addClass("name").text(item.name))
|
|
||||||
.append($("<td />").addClass("preparation").text(item.preparation))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$ingredientBulkList.append(
|
|
||||||
$("<tr />")
|
|
||||||
.append($("<td />").attr("colspan", "4").text("<Cannot Parse>"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$bulkIngredientsModal
|
|
||||||
.on('show.bs.modal', function (event) {
|
|
||||||
var data = getIngredients();
|
|
||||||
var x;
|
|
||||||
var text = [];
|
|
||||||
|
|
||||||
for (x = 0; x < data.length; x++) {
|
|
||||||
var item = data[x];
|
|
||||||
|
|
||||||
text.push(
|
|
||||||
item.quantity + " " +
|
|
||||||
(item.units || "-") + " " +
|
|
||||||
item.name +
|
|
||||||
(item.preparation ? (", " + item.preparation) : "")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ingredientBulkInput.val(text.join("\n"));
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
parseBulkIngredients();
|
|
||||||
autosize.update($ingredientBulkInput);
|
|
||||||
}, 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
$ingredientBulkInput.on('keyup', function() {
|
|
||||||
parseBulkIngredients();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#bulkIngredientAddSubmit").on("click", function() {
|
|
||||||
var parsed = $bulkIngredientsModal.data("bulkData");
|
|
||||||
var x;
|
|
||||||
|
|
||||||
$("#ingredient-list").find(".remove-button").trigger("click");
|
|
||||||
|
|
||||||
if (parsed && parsed.length) {
|
|
||||||
for (x = 0; x < parsed.length; x++) {
|
|
||||||
var item = parsed[x];
|
|
||||||
if (item) {
|
|
||||||
addIngredient(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$bulkIngredientsModal.modal('hide')
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,33 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
$(document).on("turbolinks:load", function() {
|
|
||||||
$(".recipe-view ul.ingredients").checkable();
|
|
||||||
$(".recipe-view div.steps ol").checkable();
|
|
||||||
|
|
||||||
var $searchBtn = $("#recipe_index_search_button");
|
|
||||||
|
|
||||||
if ($searchBtn.length) {
|
|
||||||
var $form = $("#search_form");
|
|
||||||
var $nameInput = $("#name_search");
|
|
||||||
var $tagsInput = $("#tags_search");
|
|
||||||
|
|
||||||
$form.submit(function() {
|
|
||||||
$("#criteria_name").val($nameInput.val());
|
|
||||||
$("#criteria_tags").val($tagsInput.val());
|
|
||||||
});
|
|
||||||
|
|
||||||
$searchBtn.click(function(evt) {
|
|
||||||
$form.submit();
|
|
||||||
});
|
|
||||||
|
|
||||||
$nameInput.add($tagsInput).on('keydown', function(evt) {
|
|
||||||
if (evt.which == 13) {
|
|
||||||
console.log('keydown enter pressed');
|
|
||||||
$form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,133 +0,0 @@
|
|||||||
|
|
||||||
(function ($) {
|
|
||||||
|
|
||||||
var pluginName = "starRating";
|
|
||||||
var defaultOptions = {
|
|
||||||
starCount: 5,
|
|
||||||
readOnly: false,
|
|
||||||
interval: 1,
|
|
||||||
size: '30px'
|
|
||||||
};
|
|
||||||
|
|
||||||
var methods = {
|
|
||||||
initialize: function(opts) {
|
|
||||||
return this.each(function() {
|
|
||||||
var $input = $(this);
|
|
||||||
var attrOpts = {};
|
|
||||||
var inputData = $input.data();
|
|
||||||
|
|
||||||
if (inputData[pluginName.toLowerCase()] === true) {
|
|
||||||
if (console && console.log) {
|
|
||||||
console.log("star rating has already been initialized; skipping...");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$input.attr("data-" + pluginName.toLowerCase(), "true");
|
|
||||||
|
|
||||||
if (inputData.interval) {
|
|
||||||
attrOpts.interval = inputData.interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputData.starcount) {
|
|
||||||
attrOpts.starCount = inputData.starcount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputData.size) {
|
|
||||||
attrOpts.size = inputData.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($input.is(":disabled")) {
|
|
||||||
attrOpts.readOnly = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var options = _.extend({}, defaultOptions, attrOpts, opts);
|
|
||||||
|
|
||||||
var $widget = $("<span />").addClass("star-rating").css({'font-size': options.size});
|
|
||||||
var $emptySet = $("<span />").addClass("empty-set").appendTo($widget);
|
|
||||||
var $filledSet = $("<span />").addClass("filled-set").appendTo($widget);
|
|
||||||
|
|
||||||
options.$input = $input;
|
|
||||||
options.$emptySet = $emptySet;
|
|
||||||
|
|
||||||
for (var x = 1; x <= options.starCount; x++) {
|
|
||||||
$emptySet.append(
|
|
||||||
$("<span />").addClass("star empty")
|
|
||||||
);
|
|
||||||
|
|
||||||
$filledSet.append(
|
|
||||||
$("<span />").addClass("star full")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$widget.data(pluginName + ".options", options);
|
|
||||||
|
|
||||||
$input.data(pluginName, true).hide().after($widget);
|
|
||||||
|
|
||||||
privateMethods.updateStars($widget);
|
|
||||||
|
|
||||||
if (!options.readOnly) {
|
|
||||||
$widget
|
|
||||||
.on("click." + pluginName, function(e) {
|
|
||||||
var value = privateMethods.calculateRating($widget, e.pageX);
|
|
||||||
privateMethods.setValue($widget, value);
|
|
||||||
})
|
|
||||||
.on("mousemove." + pluginName, function(e) {
|
|
||||||
var value = privateMethods.calculateRating($widget, e.pageX);
|
|
||||||
privateMethods.updateStars($widget, value);
|
|
||||||
})
|
|
||||||
.on("mouseleave." + pluginName, function (e) {
|
|
||||||
privateMethods.updateStars($widget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$input
|
|
||||||
.on("change." + pluginName, function() {
|
|
||||||
privateMethods.updateStars($widget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var privateMethods = {
|
|
||||||
updateStars: function($widget, value) {
|
|
||||||
var options = $widget.data(pluginName + ".options");
|
|
||||||
value = (value == null ? (parseFloat(options.$input.val() || 0)) : value);
|
|
||||||
$widget.find(".filled-set").css({width: privateMethods.calculateWidth($widget, value)});
|
|
||||||
},
|
|
||||||
|
|
||||||
setValue: function($widget, value) {
|
|
||||||
var options = $widget.data(pluginName + ".options");
|
|
||||||
options.$input.val(value);
|
|
||||||
privateMethods.updateStars($widget);
|
|
||||||
},
|
|
||||||
|
|
||||||
calculateWidth: function($widget, value) {
|
|
||||||
var options = $widget.data(pluginName + ".options");
|
|
||||||
var width = options.$emptySet.width();
|
|
||||||
return ((value / options.starCount) * 100).toString() + "%";
|
|
||||||
},
|
|
||||||
|
|
||||||
// Given a screen X coordinate, calculates the nearest valid value for this rating widget
|
|
||||||
calculateRating: function($widget, screenX) {
|
|
||||||
var options = $widget.data(pluginName + ".options");
|
|
||||||
var offset = options.$emptySet.offset();
|
|
||||||
var width = options.$emptySet.width();
|
|
||||||
var ratio = (screenX - offset.left) / width;
|
|
||||||
ratio = Math.max(0, Math.min(1, ratio));
|
|
||||||
|
|
||||||
return Math.ceil(options.starCount * (1 / options.interval) * ratio) / (1 / options.interval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn[pluginName] = function(method) {
|
|
||||||
if (methods[method]) {
|
|
||||||
return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
|
|
||||||
} else if (typeof method === 'object' || ! method) {
|
|
||||||
return methods.initialize.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
$.error('Method ' + method + ' does not exist on jQuery.' + pluginName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,112 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
var pluginName = "typeahead_search";
|
|
||||||
|
|
||||||
var defaultOptions = {
|
|
||||||
};
|
|
||||||
|
|
||||||
var methods = {
|
|
||||||
initialize: function (opts, sources) {
|
|
||||||
|
|
||||||
return this.each(function() {
|
|
||||||
var options = $.extend({}, defaultOptions, opts);
|
|
||||||
var $this = $(this);
|
|
||||||
|
|
||||||
if ($this.data(pluginName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this.data(pluginName, {options: options});
|
|
||||||
|
|
||||||
var $inputGroup = $('<div class="input-group" />');
|
|
||||||
var $btnSpan = $("<span class='input-group-btn' />");
|
|
||||||
var $btn = $("<button class='btn btn-default' type='button' />").append($("<span />").addClass("glyphicon glyphicon-search"));
|
|
||||||
|
|
||||||
$btnSpan.append($btn);
|
|
||||||
|
|
||||||
$this.after($inputGroup);
|
|
||||||
$this.detach();
|
|
||||||
$inputGroup.append($this);
|
|
||||||
$inputGroup.append($btnSpan);
|
|
||||||
|
|
||||||
$this.typeahead(opts, sources);
|
|
||||||
|
|
||||||
$btn.on("click", function(evt) {
|
|
||||||
privateMethods.search($this);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this
|
|
||||||
.on("typeahead:change", function(evt, value) {
|
|
||||||
privateMethods.change($this, value);
|
|
||||||
})
|
|
||||||
.on("typeahead:select", function(evt, value) {
|
|
||||||
privateMethods.select($this, value);
|
|
||||||
})
|
|
||||||
.on("typeahead:autocomplete", function(evt, value) {
|
|
||||||
privateMethods.autocomplete($this, value);
|
|
||||||
})
|
|
||||||
.on("keydown", function(evt) {
|
|
||||||
if (evt.keyCode == 13) {
|
|
||||||
evt.preventDefault();
|
|
||||||
privateMethods.search($this);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
val: function() {
|
|
||||||
if (this.length) {
|
|
||||||
return $(this[0]).typeahead("val");
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var privateMethods = {
|
|
||||||
change: function($this, value) {
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
select: function($this, item) {
|
|
||||||
$this.trigger("typeahead_search:selected", item);
|
|
||||||
},
|
|
||||||
|
|
||||||
autocomplete: function($this, item) {
|
|
||||||
$this.trigger("typeahead_search:selected", item);
|
|
||||||
},
|
|
||||||
|
|
||||||
search: function($this) {
|
|
||||||
var options = privateMethods.options($this);
|
|
||||||
var input = $this.typeahead("val");
|
|
||||||
|
|
||||||
if (input.length && options.searchUrl && options.searchUrl.length) {
|
|
||||||
$.get({
|
|
||||||
url: options.searchUrl,
|
|
||||||
data: {query: input},
|
|
||||||
dataType: 'html',
|
|
||||||
success: function(data) {
|
|
||||||
$(options.resultsContainer).empty().append(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$this.typeahead("close");
|
|
||||||
},
|
|
||||||
|
|
||||||
options: function($this) {
|
|
||||||
return $this.data(pluginName).options;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn[pluginName] = function (method) {
|
|
||||||
if (methods[method]) {
|
|
||||||
return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
|
|
||||||
} else if (typeof method === 'object' || ! method) {
|
|
||||||
return methods.initialize.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
$.error('Method ' + method + ' does not exist on jQuery.' + pluginName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -1,70 +0,0 @@
|
|||||||
(function($) {
|
|
||||||
|
|
||||||
var pluginName = "typeahead_selector";
|
|
||||||
|
|
||||||
var defaultOptions = {
|
|
||||||
};
|
|
||||||
|
|
||||||
var methods = {
|
|
||||||
initialize: function (opts, sources) {
|
|
||||||
|
|
||||||
return this.each(function() {
|
|
||||||
var options = $.extend({}, defaultOptions, opts);
|
|
||||||
var $this = $(this);
|
|
||||||
$this.typeahead(opts, sources);
|
|
||||||
|
|
||||||
$this
|
|
||||||
.on("typeahead:change", function(evt, value) {
|
|
||||||
privateMethods.change($this, value);
|
|
||||||
})
|
|
||||||
.on("typeahead:select", function(evt, value) {
|
|
||||||
privateMethods.select($this, value);
|
|
||||||
})
|
|
||||||
.on("typeahead:autocomplete", function(evt, value) {
|
|
||||||
privateMethods.autocomplete($this, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
val: function() {
|
|
||||||
if (this.length) {
|
|
||||||
return $(this[0]).data('typeahead_selected');
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var privateMethods = {
|
|
||||||
change: function($this, value) {
|
|
||||||
var item = $this.data('typeahead_pending');
|
|
||||||
if (item) {
|
|
||||||
$this.data('typeahead_pending', null);
|
|
||||||
$this.data('typeahead_selected', item);
|
|
||||||
$this.trigger("typeahead_selector:selected", item);
|
|
||||||
} else {
|
|
||||||
$this.data('typeahead_selected', null);
|
|
||||||
$this.trigger("typeahead_selector:invalid", value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
select: function($this, item) {
|
|
||||||
$this.data('typeahead_pending', item);
|
|
||||||
},
|
|
||||||
|
|
||||||
autocomplete: function($this, item) {
|
|
||||||
$this.data('typeahead_pending', item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn[pluginName] = function (method) {
|
|
||||||
if (methods[method]) {
|
|
||||||
return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
|
|
||||||
} else if (typeof method === 'object' || ! method) {
|
|
||||||
return methods.initialize.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
$.error('Method ' + method + ' does not exist on jQuery.' + pluginName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
})(jQuery);
|
|
@ -10,97 +10,4 @@
|
|||||||
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new
|
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new
|
||||||
* file per style scope.
|
* file per style scope.
|
||||||
*
|
*
|
||||||
*= require flash_messages
|
|
||||||
*= require chosen
|
|
||||||
*= require codemirror
|
|
||||||
*= require bootstrap-datepicker3
|
|
||||||
*= require bootstrap-tagsinput
|
|
||||||
*= require font_references
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import "bootstrap-sprockets";
|
|
||||||
@import "journal_custom_colors";
|
|
||||||
@import "journal/_variables";
|
|
||||||
@import "bootstrap";
|
|
||||||
@import "journal/_bootswatch";
|
|
||||||
|
|
||||||
@import "typeahead-bootstrap";
|
|
||||||
@import "recipes";
|
|
||||||
@import "star_rating";
|
|
||||||
@import "codemirror_custom";
|
|
||||||
|
|
||||||
// Skin overrides
|
|
||||||
.has-error {
|
|
||||||
.help-block,
|
|
||||||
.control-label,
|
|
||||||
.radio,
|
|
||||||
.checkbox,
|
|
||||||
.radio-inline,
|
|
||||||
.checkbox-inline,
|
|
||||||
&.radio label,
|
|
||||||
&.checkbox label,
|
|
||||||
&.radio-inline label,
|
|
||||||
&.checkbox-inline label,
|
|
||||||
.form-control-feedback {
|
|
||||||
color: $state-danger-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control,
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: $state-danger-border;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$footer_height: 40px;
|
|
||||||
|
|
||||||
html {
|
|
||||||
position: relative;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
/* Margin bottom by footer height */
|
|
||||||
margin-bottom: $footer_height + 20;
|
|
||||||
background: image_url("grey_wash_wall.png");
|
|
||||||
}
|
|
||||||
|
|
||||||
#main_container {
|
|
||||||
background: white;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: $footer_height;
|
|
||||||
background-color: $gray-lighter;
|
|
||||||
border-top: solid 1px $gray-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.sorted {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.sorted.asc:after {
|
|
||||||
content: " ";
|
|
||||||
position: absolute;
|
|
||||||
margin: 8px 0 0 6px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 5px solid transparent;
|
|
||||||
border-right: 5px solid transparent;
|
|
||||||
|
|
||||||
border-top: 5px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.sorted.desc:after {
|
|
||||||
content: " ";
|
|
||||||
position: absolute;
|
|
||||||
margin: 8px 0 0 6px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 5px solid transparent;
|
|
||||||
border-right: 5px solid transparent;
|
|
||||||
|
|
||||||
border-bottom: 5px solid black;
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
.CodeMirror {
|
|
||||||
border: 1px solid $gray-light;
|
|
||||||
font-family: inconsolata monospace;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
|
|
||||||
#flashHolder {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#flashContainer {
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert.popup {
|
|
||||||
position: fixed;
|
|
||||||
top: 15px;
|
|
||||||
left: 50%;
|
|
||||||
width: 30em;
|
|
||||||
margin-left: -15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash {
|
|
||||||
text-align: center;
|
|
||||||
width: 30em;
|
|
||||||
padding: 5px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background-color: #fff6ff;
|
|
||||||
border: 3px solid #fda8a8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
background-color: #ffffdd;
|
|
||||||
border: 3px solid #ffdd00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
|
||||||
background-color: #D8F6CE;
|
|
||||||
border: 3px solid #3ADF00;
|
|
||||||
}
|
|
@ -1,105 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
src: local('Open Sans Light'), local('OpenSans-Light'), font_url("open-sans-light.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('Open Sans'), local('OpenSans'), font_url("open-sans.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('Open Sans Bold'), local('OpenSans-Bold'), font_url("open-sans-bold.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('Open Sans Italic'), local('OpenSans-Italic'), font_url("open-sans-italic.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 300;
|
|
||||||
src: local('Open Sans Light Italic'), local('OpenSansLight-Italic'), font_url("open-sans-light-italic.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Open Sans';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), font_url("open-sans-bold-italic.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
src: local('Roboto Light'), local('Roboto-Light'), font_url("roboto-light.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('Roboto'), local('Roboto-Regular'), font_url("roboto.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
src: local('Roboto Medium'), local('Roboto-Medium'), font_url("roboto-medium.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('Roboto Bold'), local('Roboto-Bold'), font_url("roboto-bold.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Raleway';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('Raleway'), font_url("raleway.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Raleway';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('Raleway Bold'), local('Raleway-Bold'), font_url("raleway-bold.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'News Cycle';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('News Cycle'), local('NewsCycle'), font_url("news-cycle.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'News Cycle';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('News Cycle Bold'), local('NewsCycle-Bold'), font_url("news-cycle-bold.woff2") format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inconsolata';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local('Inconsolata Regular'), local('Inconsolata-Regular'), font_url('inconsolata.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inconsolata';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: local('Inconsolata Bold'), local('Inconsolata-Bold'), font_url('inconsolata-bold.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
// Place all the styles related to the ingredients controller here.
|
|
||||||
// They will automatically be included in application.css.
|
|
||||||
// You can use Sass (SCSS) here: http://sass-lang.com/
|
|
@ -1,5 +0,0 @@
|
|||||||
|
|
||||||
$brand-primary: darken(#93C54B, 10%);
|
|
||||||
$brand-success: $brand-primary;
|
|
||||||
$text-color: #3E3F3A;
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
|||||||
// Place all the styles related to the recipes controller here.
|
|
||||||
// They will automatically be included in application.css.
|
|
||||||
// You can use Sass (SCSS) here: http://sass-lang.com/
|
|
||||||
|
|
||||||
div.table_tags {
|
|
||||||
width: 275px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.recipe_list_controls {
|
|
||||||
width: 85px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin editor {
|
|
||||||
|
|
||||||
@extend .well;
|
|
||||||
@extend .well-sm;
|
|
||||||
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-top: 4px;
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-button {
|
|
||||||
@extend .btn;
|
|
||||||
@extend .btn-danger;
|
|
||||||
@extend .btn-sm;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.ingredient-editor {
|
|
||||||
@include editor;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
div#ingredient-list {
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#ingredient-list {
|
|
||||||
|
|
||||||
@media (min-width: $screen-md-min) {
|
|
||||||
.ingredient-editor .control-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredient-editor:first-child .control-label {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.recipe-view {
|
|
||||||
|
|
||||||
.source {
|
|
||||||
@extend .col-xs-6;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients div {
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps div {
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.checked {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
span.star-rating {
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
color: gold;
|
|
||||||
cursor: default;
|
|
||||||
position: relative;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.empty-set {
|
|
||||||
color: gray;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filled-set {
|
|
||||||
overflow-x: hidden;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.star {
|
|
||||||
|
|
||||||
@extend .glyphicon;
|
|
||||||
|
|
||||||
&.empty {
|
|
||||||
@extend .glyphicon-star-empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.full {
|
|
||||||
@extend .glyphicon-star;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
span.twitter-typeahead .tt-menu,
|
|
||||||
span.twitter-typeahead .tt-dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: none;
|
|
||||||
float: left;
|
|
||||||
min-width: 160px;
|
|
||||||
padding: 5px 0;
|
|
||||||
margin: 2px 0 0;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: left;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #cccccc;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.twitter-typeahead .tt-hint {
|
|
||||||
color: #A5A5A5;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.twitter-typeahead .tt-suggestion {
|
|
||||||
display: block;
|
|
||||||
padding: 3px 20px;
|
|
||||||
clear: both;
|
|
||||||
font-weight: normal;
|
|
||||||
line-height: 1.42857143;
|
|
||||||
color: #333333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.twitter-typeahead .tt-suggestion.tt-cursor,
|
|
||||||
span.twitter-typeahead .tt-suggestion:hover,
|
|
||||||
span.twitter-typeahead .tt-suggestion:focus {
|
|
||||||
color: #ffffff;
|
|
||||||
text-decoration: none;
|
|
||||||
outline: 0;
|
|
||||||
background-color: #337ab7;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.twitter-typeahead {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.input-group span.twitter-typeahead {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
.input-group span.twitter-typeahead .tt-menu,
|
|
||||||
.input-group span.twitter-typeahead .tt-dropdown-menu {
|
|
||||||
top: 32px !important;
|
|
||||||
}
|
|
||||||
.input-group.input-group-sm span.twitter-typeahead .tt-menu,
|
|
||||||
.input-group.input-group-sm span.twitter-typeahead .tt-dropdown-menu {
|
|
||||||
top: 28px !important;
|
|
||||||
}
|
|
||||||
.input-group.input-group-lg span.twitter-typeahead .tt-menu,
|
|
||||||
.input-group.input-group-lg span.twitter-typeahead .tt-dropdown-menu {
|
|
||||||
top: 44px !important;
|
|
||||||
}
|
|
@ -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"
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
class HomeController < ApplicationController
|
class HomeController < ApplicationController
|
||||||
|
|
||||||
|
skip_forgery_protection
|
||||||
|
|
||||||
|
def index
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
def about
|
def about
|
||||||
|
|
||||||
end
|
end
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
class IngredientsController < ApplicationController
|
class IngredientsController < ApplicationController
|
||||||
|
|
||||||
before_action :set_ingredient, only: [:edit, :update, :destroy]
|
before_action :set_ingredient, only: [:show, :edit, :update, :destroy]
|
||||||
|
|
||||||
before_action :ensure_valid_user, except: [:index]
|
before_action :ensure_valid_user, except: [:index, :show]
|
||||||
|
|
||||||
# GET /ingredients
|
# GET /ingredients
|
||||||
# GET /ingredients.json
|
# GET /ingredients.json
|
||||||
def index
|
def index
|
||||||
@ingredients = Ingredient.all.order(:name)
|
@ingredients = Ingredient.all.order(:name).page(params[:page]).per(params[:per])
|
||||||
|
if params[:name].present?
|
||||||
|
@ingredients = @ingredients.matches_tokens(:name, params[:name].split.take(4))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /ingredients/new
|
# GET /ingredients/new
|
||||||
@ -32,7 +39,7 @@ class IngredientsController < ApplicationController
|
|||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
if @ingredient.save
|
if @ingredient.save
|
||||||
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
|
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
|
||||||
format.json { render :show, status: :created, location: @ingredient }
|
format.json { render json: { success: true }, status: :created, location: @ingredient }
|
||||||
else
|
else
|
||||||
format.html { render :new }
|
format.html { render :new }
|
||||||
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
||||||
@ -51,7 +58,7 @@ class IngredientsController < ApplicationController
|
|||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
if @ingredient.save
|
if @ingredient.save
|
||||||
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' }
|
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' }
|
||||||
format.json { render :show, status: :ok, location: @ingredient }
|
format.json { render json: { success: true }, status: :ok, location: @ingredient }
|
||||||
else
|
else
|
||||||
format.html { render :edit }
|
format.html { render :edit }
|
||||||
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
||||||
@ -85,9 +92,7 @@ class IngredientsController < ApplicationController
|
|||||||
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
|
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
|
||||||
end
|
end
|
||||||
|
|
||||||
respond_to do |format|
|
render :show
|
||||||
format.js {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def prefetch
|
def prefetch
|
||||||
@ -127,7 +132,7 @@ class IngredientsController < ApplicationController
|
|||||||
|
|
||||||
# Never trust parameters from the scary internet, only allow the white list through.
|
# Never trust parameters from the scary internet, only allow the white list through.
|
||||||
def ingredient_params
|
def ingredient_params
|
||||||
params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ingredient_units_attributes => [:name, :gram_weight, :id, :_destroy])
|
params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ash, :iron, :magnesium, :phosphorus, :potassium, :zinc, :copper, :manganese, :vit_c, :vit_b6, :vit_b12, :vit_a, :vit_e, :vit_d, :cholesterol, :ingredient_units_attributes => [:name, :gram_weight, :id, :_destroy])
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversion_params
|
def conversion_params
|
||||||
|
@ -2,12 +2,12 @@ class LogsController < ApplicationController
|
|||||||
|
|
||||||
before_action :ensure_valid_user
|
before_action :ensure_valid_user
|
||||||
|
|
||||||
before_action :set_log, only: [:show, :edit, :update, :destroy]
|
before_action :set_log, only: [:show, :update, :destroy]
|
||||||
before_action :set_recipe, only: [:new, :create]
|
before_action :set_recipe, only: [:new, :create]
|
||||||
before_action :require_recipe, only: [:new, :create]
|
before_action :require_recipe, only: [:new, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@logs = Log.for_user(current_user).order(:date)
|
@logs = Log.for_user(current_user).order(date: :desc).page(params[:page]).per(params[:per])
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -21,9 +21,9 @@ class LogsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
ensure_owner(@log) do
|
ensure_owner(@log) do
|
||||||
if @log.update(log_params)
|
if @log.update(log_params)
|
||||||
redirect_to logs_path, notice: 'Log Entry was successfully updated.'
|
render json: { success: true }
|
||||||
else
|
else
|
||||||
render :edit
|
render json: @log.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -45,9 +45,9 @@ class LogsController < ApplicationController
|
|||||||
@log.source_recipe = @recipe
|
@log.source_recipe = @recipe
|
||||||
|
|
||||||
if @log.save
|
if @log.save
|
||||||
redirect_to logs_path, notice: 'Log Entry was successfully created.'
|
render json: { success: true }
|
||||||
else
|
else
|
||||||
render :new
|
render json: @log.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ class LogsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_log
|
def set_log
|
||||||
@log = Log.find(params[:id])
|
@log = Log.includes({recipe: {recipe_ingredients: {ingredient: :ingredient_units} }}).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_recipe
|
def set_recipe
|
||||||
|
@ -77,6 +77,6 @@ class NotesController < ApplicationController
|
|||||||
|
|
||||||
# Never trust parameters from the scary internet, only allow the white list through.
|
# Never trust parameters from the scary internet, only allow the white list through.
|
||||||
def note_params
|
def note_params
|
||||||
params.require(:note).permit(:user_id, :content)
|
params.require(:note).permit(:content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -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,23 +58,25 @@ 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
|
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
|
||||||
@recipe.deleted = true
|
@recipe.deleted = true
|
||||||
|
@recipe.save!(validate: false)
|
||||||
|
|
||||||
if @recipe.save(validate: false)
|
render json: { success: true }
|
||||||
redirect_to recipes_url, notice: 'Recipe was successfully destroyed.'
|
|
||||||
else
|
|
||||||
redirect_to recipes_url, error: 'Recipe could not be destroyed.'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -108,4 +90,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,6 +1,12 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
|
|
||||||
before_action :ensure_valid_user, except: [:login, :verify_login, :new, :create]
|
UserProxy = Struct.new(:user_id)
|
||||||
|
|
||||||
|
before_action :ensure_valid_user, except: [:show, :login, :verify_login, :new, :create]
|
||||||
|
skip_before_action :verify_authenticity_token, only: [:verify_login]
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
def login
|
def login
|
||||||
|
|
||||||
@ -9,18 +15,24 @@ class UsersController < ApplicationController
|
|||||||
def logout
|
def logout
|
||||||
set_current_user(nil)
|
set_current_user(nil)
|
||||||
session.destroy
|
session.destroy
|
||||||
flash[:notice] = "Logged out"
|
|
||||||
redirect_to root_path
|
respond_to do |format|
|
||||||
|
format.html { redirect_to root_path, notice: "Logged out" }
|
||||||
|
format.json { render json: { success: true } }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_login
|
def verify_login
|
||||||
if user = User.authenticate(params[:username], params[:password])
|
|
||||||
set_current_user(user)
|
respond_to do |format|
|
||||||
flash[:notice] = "Welcome, #{user.display_name}"
|
if user = User.authenticate(params[:username], params[:password])
|
||||||
redirect_to root_path
|
set_current_user(user)
|
||||||
else
|
format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
|
||||||
flash[:error] = "Invalid credentials"
|
format.json { render json: { success: true, user: { id: user.id, name: user.display_name, admin: user.admin? } } }
|
||||||
render :login
|
else
|
||||||
|
format.html { flash[:error] = "Invalid credentials"; render :login }
|
||||||
|
format.json { render json: { success: false, message: 'Invalid Credentials', user: nil } }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -31,11 +43,15 @@ class UsersController < ApplicationController
|
|||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
if @user.save
|
respond_to do |format|
|
||||||
set_current_user(@user)
|
if @user.save
|
||||||
redirect_to root_path, notice: 'User was successfully created.'
|
set_current_user(@user)
|
||||||
else
|
format.html { redirect_to root_path, notice: 'User created.' }
|
||||||
render action: :new
|
format.json { render :show, status: :created, location: @user }
|
||||||
|
else
|
||||||
|
format.html { render :new }
|
||||||
|
format.json { render json: @user.errors, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -45,10 +61,15 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
@user = current_user
|
@user = current_user
|
||||||
if @user.update(user_params)
|
|
||||||
redirect_to root_path, notice: 'User account updated'
|
respond_to do |format|
|
||||||
else
|
if @user.update(user_params)
|
||||||
render action: 'edit'
|
format.html { redirect_to root_path, notice: 'User updated.' }
|
||||||
|
format.json { render :show, status: :created, location: @user }
|
||||||
|
else
|
||||||
|
format.html { render :edit }
|
||||||
|
format.json { render json: @user.errors, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -13,57 +13,4 @@ module ApplicationHelper
|
|||||||
decorated
|
decorated
|
||||||
end
|
end
|
||||||
|
|
||||||
def timestamp(time)
|
|
||||||
time ? time.strftime('%D %R') : ''
|
|
||||||
end
|
|
||||||
|
|
||||||
def nav_items
|
|
||||||
nav = [
|
|
||||||
nav_item('Recipes', recipes_path, 'recipes'),
|
|
||||||
nav_item('Ingredients', ingredients_path, 'ingredients'),
|
|
||||||
nav_item('Logs', logs_path, 'logs', current_user?),
|
|
||||||
nav_item('Calculator', calculator_path, 'calculator'),
|
|
||||||
nav_item('About', about_path, 'home'),
|
|
||||||
nav_item('Notes', notes_path, 'notes', current_user?),
|
|
||||||
nav_item('Admin', admin_users_path, 'admin/users', current_user? && current_user.admin?)
|
|
||||||
]
|
|
||||||
|
|
||||||
nav.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def profile_nav_items
|
|
||||||
if current_user
|
|
||||||
[content_tag('li', class: 'dropdown') do
|
|
||||||
li_cnt = ''.html_safe
|
|
||||||
|
|
||||||
li_cnt << link_to('#', class: 'dropdown-toggle', data: {toggle: 'dropdown'}, role: 'button') do
|
|
||||||
''.html_safe << "#{current_user.display_name}" << content_tag('span', '', class: 'caret')
|
|
||||||
end
|
|
||||||
|
|
||||||
li_cnt << content_tag('ul', class: 'dropdown-menu') do
|
|
||||||
''.html_safe << nav_item('Profile', edit_user_path) << nav_item('Logout', logout_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
li_cnt
|
|
||||||
end]
|
|
||||||
else
|
|
||||||
[nav_item('Login', login_path)]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def nav_item(name, url, controller = nil, visible = true)
|
|
||||||
!visible ? nil : content_tag('li', link_to(name, url), class: active_for_controller(controller))
|
|
||||||
end
|
|
||||||
|
|
||||||
def active_for_controller(controller)
|
|
||||||
if current_controller == controller
|
|
||||||
'active'
|
|
||||||
else
|
|
||||||
''
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_controller
|
|
||||||
params[:controller]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@ -1,11 +1,4 @@
|
|||||||
module IngredientsHelper
|
module IngredientsHelper
|
||||||
|
|
||||||
def ndbn_button_class(ingredient)
|
|
||||||
if ingredient.ndbn.present?
|
|
||||||
'btn btn-success'
|
|
||||||
else
|
|
||||||
'btn btn-default'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
end
|
||||||
|
@ -1,71 +1,3 @@
|
|||||||
module RecipesHelper
|
module RecipesHelper
|
||||||
def recipe_time(recipe)
|
|
||||||
output = ''.html_safe
|
|
||||||
|
|
||||||
if recipe.total_time.present?
|
|
||||||
output << "#{humanize_seconds(recipe.total_time.to_i.minutes)}"
|
|
||||||
if recipe.active_time.present?
|
|
||||||
output << " (#{humanize_seconds(recipe.active_time.to_i.minutes)} active)"
|
|
||||||
end
|
|
||||||
elsif recipe.active_time.present?
|
|
||||||
output << humanize_seconds(recipe.active_time.to_i.minutes)
|
|
||||||
end
|
|
||||||
|
|
||||||
output
|
|
||||||
end
|
|
||||||
|
|
||||||
def humanize_seconds(secs)
|
|
||||||
[[60, :s], [60, :m], [24, :h], [1000, :d]].map{ |count, name|
|
|
||||||
if secs > 0
|
|
||||||
secs, n = secs.divmod(count)
|
|
||||||
n == 0 ? nil : "#{n.to_i} #{name}"
|
|
||||||
end
|
|
||||||
}.compact.reverse.join(' ')
|
|
||||||
end
|
|
||||||
|
|
||||||
def nutrient_row(recipe, nutrients, heading, nutrient_name)
|
|
||||||
content_tag('tr') do
|
|
||||||
[
|
|
||||||
content_tag('td', heading),
|
|
||||||
recipe.parsed_yield ? content_tag('td', nutrients.send("#{nutrient_name}_per".to_sym, recipe.parsed_yield.number)) : nil,
|
|
||||||
content_tag('td', nutrients.send("#{nutrient_name}".to_sym))
|
|
||||||
].compact.join("\n".html_safe).html_safe
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def index_sort_header(text, field, criteria)
|
|
||||||
uri = URI(request.original_fullpath)
|
|
||||||
query = Rack::Utils.parse_query(uri.query)
|
|
||||||
|
|
||||||
directions = [:asc, :desc]
|
|
||||||
|
|
||||||
current_field = criteria.sort_column
|
|
||||||
current_direction = criteria.sort_direction
|
|
||||||
field_param = 'criteria[sort_column]'
|
|
||||||
direction_param = 'criteria[sort_direction]'
|
|
||||||
|
|
||||||
if request.get?
|
|
||||||
is_sorted = current_field == field.to_sym
|
|
||||||
|
|
||||||
if is_sorted && directions.include?(current_direction)
|
|
||||||
direction = (directions.reject { |d| d == current_direction }).first
|
|
||||||
else
|
|
||||||
direction = directions.first
|
|
||||||
end
|
|
||||||
|
|
||||||
if is_sorted && direction == :asc
|
|
||||||
link_class = 'sorted desc'
|
|
||||||
elsif is_sorted && direction == :desc
|
|
||||||
link_class = 'sorted asc'
|
|
||||||
else
|
|
||||||
link_class = 'sorted'
|
|
||||||
end
|
|
||||||
|
|
||||||
query[field_param.to_s] = field.to_s
|
|
||||||
query[direction_param.to_s] = direction.to_s
|
|
||||||
link_to text, "#{uri.path}?#{query.to_query}", class: link_class
|
|
||||||
else
|
|
||||||
text
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
77
app/javascript/components/App.vue
Normal file
77
app/javascript/components/App.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<vue-progress-bar></vue-progress-bar>
|
||||||
|
<app-navbar></app-navbar>
|
||||||
|
<section id="app" class="">
|
||||||
|
<div class="container">
|
||||||
|
<router-view v-if="!hasError"></router-view>
|
||||||
|
<div v-else>
|
||||||
|
<h1>Error!</h1>
|
||||||
|
<p>{{error}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import { mapMutations, mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
api: api
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
hasError: state => state.error !== null,
|
||||||
|
error: state => state.error,
|
||||||
|
authChecked: state => state.authChecked
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
isLoading(val) {
|
||||||
|
if (val) {
|
||||||
|
this.$Progress.start();
|
||||||
|
} else {
|
||||||
|
this.$Progress.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
if (this.user === null && this.authChecked === false) {
|
||||||
|
this.checkAuthentication();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
263
app/javascript/components/AppAutocomplete.vue
Normal file
263
app/javascript/components/AppAutocomplete.vue
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref="textInput"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
|
||||||
|
:id="id"
|
||||||
|
:name="name"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:value="rawValue"
|
||||||
|
:class="finalInputClass"
|
||||||
|
|
||||||
|
@click="clickHandler"
|
||||||
|
@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;
|
||||||
|
},
|
||||||
|
|
||||||
|
clickHandler(evt) {
|
||||||
|
this.$emit("inputClick", evt);
|
||||||
|
},
|
||||||
|
|
||||||
|
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>
|
47
app/javascript/components/AppConfirm.vue
Normal file
47
app/javascript/components/AppConfirm.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<app-modal :open="open" :title="message" @dismiss="runCancel">
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="runConfirm">OK</button>
|
||||||
|
<button type="button" class="button" @click="runCancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</app-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
cancel: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
confirm: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'Are you sure?'
|
||||||
|
},
|
||||||
|
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
runConfirm() {
|
||||||
|
this.confirm();
|
||||||
|
},
|
||||||
|
|
||||||
|
runCancel() {
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
42
app/javascript/components/AppDatePicker.vue
Normal file
42
app/javascript/components/AppDatePicker.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<app-text-field :value="stringValue" @input="input" :label="label" type="date"></app-text-field>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import DateTimeUtils from "../lib/DateTimeUtils";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
required: false,
|
||||||
|
type: [Date, String]
|
||||||
|
},
|
||||||
|
|
||||||
|
label: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
stringValue() {
|
||||||
|
const d = DateTimeUtils.toDate(this.value);
|
||||||
|
return DateTimeUtils.formatDateForEdit(d);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
input(val) {
|
||||||
|
let d = DateTimeUtils.toDate(val + " 00:00");
|
||||||
|
this.$emit("input", d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
63
app/javascript/components/AppDateTime.vue
Normal file
63
app/javascript/components/AppDateTime.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<input v-if="useInput" class="text" type="text" readonly :value="friendlyString" />
|
||||||
|
<span v-else :title="fullString">{{ friendlyString }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import DateTimeUtils from "../lib/DateTimeUtils";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
dateTime: {
|
||||||
|
required: true,
|
||||||
|
type: [Date, String]
|
||||||
|
},
|
||||||
|
|
||||||
|
showDate: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
|
||||||
|
showTime: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
|
||||||
|
useInput: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
dateObj() {
|
||||||
|
return DateTimeUtils.toDate(this.dateTime);
|
||||||
|
},
|
||||||
|
|
||||||
|
friendlyString() {
|
||||||
|
const parts = [];
|
||||||
|
if (this.showDate) {
|
||||||
|
parts.push(DateTimeUtils.formatDate(this.dateObj));
|
||||||
|
}
|
||||||
|
if (this.showTime) {
|
||||||
|
parts.push(DateTimeUtils.formatTime(this.dateObj, true));
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
},
|
||||||
|
|
||||||
|
fullString() {
|
||||||
|
return DateTimeUtils.formatTimestamp(this.dateObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
121
app/javascript/components/AppIcon.vue
Normal file
121
app/javascript/components/AppIcon.vue
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<span class="icon" :class="sizeClass" @click="$emit('click', $event)">
|
||||||
|
<svg v-html="svgContent" v-bind="svgAttributes"></svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import CaretBottom from "open-iconic/svg/caret-bottom";
|
||||||
|
import CaretTop from "open-iconic/svg/caret-top";
|
||||||
|
import Check from "open-iconic/svg/check";
|
||||||
|
import CircleCheck from "open-iconic/svg/circle-check.svg";
|
||||||
|
import LinkBroken from "open-iconic/svg/link-broken";
|
||||||
|
import LinkIntact from "open-iconic/svg/link-intact";
|
||||||
|
import LockLocked from "open-iconic/svg/lock-locked";
|
||||||
|
import LockUnlocked from "open-iconic/svg/lock-unlocked";
|
||||||
|
import Person from "open-iconic/svg/person";
|
||||||
|
import Pencil from "open-iconic/svg/pencil";
|
||||||
|
import Star from "open-iconic/svg/star";
|
||||||
|
import X from "open-iconic/svg/x";
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
'caret-bottom': CaretBottom,
|
||||||
|
'caret-top': CaretTop,
|
||||||
|
check: Check,
|
||||||
|
'circle-check': CircleCheck,
|
||||||
|
'link-broken': LinkBroken,
|
||||||
|
'link-intact': LinkIntact,
|
||||||
|
'lock-locked': LockLocked,
|
||||||
|
'lock-unlocked': LockUnlocked,
|
||||||
|
pencil: Pencil,
|
||||||
|
person: Person,
|
||||||
|
star: Star,
|
||||||
|
x: X
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: 'is-small',
|
||||||
|
md: '' ,
|
||||||
|
lg: 'is-medium',
|
||||||
|
xl: 'is-large'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
icon: {
|
||||||
|
validator: (i) => iconMap[i] !== undefined
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
validator: (s) => sizeMap[s] !== undefined,
|
||||||
|
default: 'md'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
svgObj() {
|
||||||
|
return iconMap[this.icon];
|
||||||
|
},
|
||||||
|
svgAttributes() {
|
||||||
|
const attrs = {
|
||||||
|
class: this.size
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let a of ['viewBox', 'xmlns']) {
|
||||||
|
if (this.svgObj.attributes[a]) {
|
||||||
|
attrs[a] = this.svgObj.attributes[a];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs;
|
||||||
|
},
|
||||||
|
svgContent() {
|
||||||
|
return this.svgObj.content;
|
||||||
|
},
|
||||||
|
sizeClass() {
|
||||||
|
return sizeMap[this.size];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: currentColor;
|
||||||
|
|
||||||
|
&.sm {
|
||||||
|
width: 0.6em;
|
||||||
|
height: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.md {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.lg {
|
||||||
|
width: 1.33em;
|
||||||
|
height: 1.33em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.xl {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*&.xl {*/
|
||||||
|
/*width: 3em;*/
|
||||||
|
/*height: 3em;*/
|
||||||
|
/*}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
68
app/javascript/components/AppModal.vue
Normal file
68
app/javascript/components/AppModal.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container">
|
||||||
|
<div ref="modal" :class="['popup', 'modal', { 'is-wide': wide, 'is-active': open && error === null }]">
|
||||||
|
<div class="modal-background" @click="close"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<slot name="title">
|
||||||
|
<p class="modal-card-title">{{ title }}</p>
|
||||||
|
<app-icon icon="x" aria-label="close" @click="close"></app-icon>
|
||||||
|
</slot>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<slot></slot>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: String,
|
||||||
|
wide: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
document.body.appendChild(this.$refs.modal);
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$refs.container.appendChild(this.$refs.modal);
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState([
|
||||||
|
'error'
|
||||||
|
])
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
close() {
|
||||||
|
this.$emit("dismiss");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
95
app/javascript/components/AppNavbar.vue
Normal file
95
app/javascript/components/AppNavbar.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item" href="/">
|
||||||
|
PARSLEY
|
||||||
|
</a>
|
||||||
|
<div class="navbar-burger" :class="{ 'is-active': menuActive}" @click="menuActive = !menuActive">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-menu" :class="{ 'is-active': menuActive}">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<a class="navbar-item" v-if="updateAvailable" href="#" @click.prevent="updateApp">UPDATE AVAILABLE!</a>
|
||||||
|
<router-link to="/" class="navbar-item">Recipes</router-link>
|
||||||
|
<router-link to="/ingredients" class="navbar-item">Ingredients</router-link>
|
||||||
|
<router-link to="/calculator" class="navbar-item">Calculator</router-link>
|
||||||
|
<router-link v-if="isLoggedIn" to="/logs" class="navbar-item">Log</router-link>
|
||||||
|
<router-link v-if="isLoggedIn" to="/notes" class="navbar-item">Notes</router-link>
|
||||||
|
<router-link to="/about" class="navbar-item">About</router-link>
|
||||||
|
<router-link v-if="isAdmin" to="/admin/users" class="navbar-item">Admin</router-link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable" >
|
||||||
|
<div v-if="isLoggedIn">
|
||||||
|
<a class="navbar-link" href="#" @click.prevent>
|
||||||
|
{{ user.name }}
|
||||||
|
</a>
|
||||||
|
<div class="navbar-dropdown is-boxed">
|
||||||
|
<router-link to="/user/edit" class="navbar-item">
|
||||||
|
Profile
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/logout" class="navbar-item">Logout</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<user-login class="navbar-link"></user-login>
|
||||||
|
<div class="navbar-dropdown is-boxed">
|
||||||
|
<router-link to="/user/new" class="navbar-item">Create Account</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import UserLogin from "./UserLogin";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import { swUpdate } from "../lib/ServiceWorker";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
menuActive: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState([
|
||||||
|
'route',
|
||||||
|
'user',
|
||||||
|
'updateAvailable'
|
||||||
|
])
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
updateApp() {
|
||||||
|
swUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
route() {
|
||||||
|
this.menuActive = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
user() {
|
||||||
|
this.menuActive = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
UserLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
100
app/javascript/components/AppPager.vue
Normal file
100
app/javascript/components/AppPager.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<nav v-show="totalPages > 1" class="pagination" role="navigation" :aria-label="pagedItemName + ' page navigation'">
|
||||||
|
<a class="pagination-previous" :title="isFirstPage ? 'This is the first page' : ''" :disabled="isFirstPage" @click.prevent="changePage(currentPage - 1)">Previous</a>
|
||||||
|
<a class="pagination-next" :title="isLastPage ? 'This is the last page' : ''" :disabled="isLastPage" @click.prevent="changePage(currentPage + 1)">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
<li v-for="page in pageItems" :key="page">
|
||||||
|
<a v-if="page > 0" class="pagination-link" :class="{'is-current': page === currentPage}" href="#" @click.prevent="changePage(page)">{{page}}</a>
|
||||||
|
<span v-else class="pagination-ellipsis">…</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
pagedItemName: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
currentPage: {
|
||||||
|
required: true,
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
|
||||||
|
totalPages: {
|
||||||
|
required: true,
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
|
||||||
|
pageWindow: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 4
|
||||||
|
},
|
||||||
|
|
||||||
|
pageOuterWindow: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
pageItems() {
|
||||||
|
const items = new Set();
|
||||||
|
|
||||||
|
for (let x = 0; x < this.pageOuterWindow; x++) {
|
||||||
|
items.add(x + 1);
|
||||||
|
items.add(this.totalPages - x);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = this.currentPage - Math.ceil(this.pageWindow / 2);
|
||||||
|
const end = this.currentPage + Math.floor(this.pageWindow / 2);
|
||||||
|
|
||||||
|
for (let x = start; x <= end; x++) {
|
||||||
|
items.add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
let emptySpace = -1;
|
||||||
|
const finalList = [];
|
||||||
|
|
||||||
|
[...items.values()].filter(p => p > 0 && p <= this.totalPages).sort((a, b) => a - b).forEach((p, idx, list) => {
|
||||||
|
finalList.push(p);
|
||||||
|
if (list[idx + 1] && list[idx + 1] !== p + 1) {
|
||||||
|
finalList.push(emptySpace--);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalList;
|
||||||
|
},
|
||||||
|
|
||||||
|
isLastPage() {
|
||||||
|
return this.currentPage === this.totalPages;
|
||||||
|
},
|
||||||
|
|
||||||
|
isFirstPage() {
|
||||||
|
return this.currentPage === 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changePage(idx) {
|
||||||
|
this.$emit("changePage", idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
ul.pagination {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
131
app/javascript/components/AppRating.vue
Normal file
131
app/javascript/components/AppRating.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<span ref="wrapper" class="rating" @click="handleClick" @mousemove="handleMousemove" @mouseleave="handleMouseleave">
|
||||||
|
<span class="set empty-set">
|
||||||
|
<app-icon v-for="i in starCount" :key="i" icon="star"></app-icon>
|
||||||
|
</span>
|
||||||
|
<span class="set filled-set" :style="filledStyle">
|
||||||
|
<app-icon v-for="i in starCount" :key="i" icon="star"></app-icon>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
starCount: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 5
|
||||||
|
},
|
||||||
|
|
||||||
|
readonly: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
step: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 0.5
|
||||||
|
},
|
||||||
|
|
||||||
|
value: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
temporaryValue: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
ratingPercent() {
|
||||||
|
return ((this.value || 0) / this.starCount) * 100.0;
|
||||||
|
},
|
||||||
|
|
||||||
|
temporaryPercent() {
|
||||||
|
if (this.temporaryValue !== null) {
|
||||||
|
return (this.temporaryValue / this.starCount) * 100.0;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
filledStyle() {
|
||||||
|
const width = this.temporaryPercent || this.ratingPercent;
|
||||||
|
return {
|
||||||
|
width: width + "%"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
handleClick(evt) {
|
||||||
|
if (this.temporaryValue !== null) {
|
||||||
|
this.$emit("input", this.temporaryValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMousemove(evt) {
|
||||||
|
if (this.readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperBox = this.$refs.wrapper.getBoundingClientRect();
|
||||||
|
const wrapperWidth = wrapperBox.right - wrapperBox.left;
|
||||||
|
const mousePosition = evt.clientX;
|
||||||
|
|
||||||
|
if (mousePosition > wrapperBox.left && mousePosition < wrapperBox.right) {
|
||||||
|
const filledRatio = ((mousePosition - wrapperBox.left) / wrapperWidth);
|
||||||
|
|
||||||
|
const totalSteps = this.starCount / this.step;
|
||||||
|
const filledSteps = Math.round(totalSteps * filledRatio);
|
||||||
|
|
||||||
|
this.temporaryValue = filledSteps * this.step;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMouseleave(evt) {
|
||||||
|
this.temporaryValue = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
@import "../styles/variables";
|
||||||
|
|
||||||
|
span.rating {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.set {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-set {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filled-set {
|
||||||
|
color: $yellow;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
85
app/javascript/components/AppTagEditor.vue
Normal file
85
app/javascript/components/AppTagEditor.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tag-editor control">
|
||||||
|
<input ref="input" type="text" class="input" :value="tagText" @input="inputHandler" @focus="getFocus" @blur="loseFocus">
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="t in value" :key="t" class="tag">{{t}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
required: true,
|
||||||
|
type: Array
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasFocus: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
tagText() {
|
||||||
|
return this.value.join(" ");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
inputHandler(el) {
|
||||||
|
let str = el.target.value;
|
||||||
|
this.checkInput(str);
|
||||||
|
this.$nextTick(() => {
|
||||||
|
el.target.value = str;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkInput(str) {
|
||||||
|
if (this.hasFocus) {
|
||||||
|
const m = str.match(/\S\s+\S*$/);
|
||||||
|
|
||||||
|
if (m !== null) {
|
||||||
|
str = str.substring(0, m.index + 1);
|
||||||
|
} else {
|
||||||
|
str = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTags = [...new Set(str.toString().split(/\s+/).filter(t => t.length > 0))];
|
||||||
|
|
||||||
|
if (!this.arraysEqual(newTags, this.value)) {
|
||||||
|
this.$emit("input", newTags);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getFocus() {
|
||||||
|
this.hasFocus = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
loseFocus() {
|
||||||
|
this.hasFocus = false;
|
||||||
|
this.checkInput(this.$refs.input.value);
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
arraysEqual(arr1, arr2) {
|
||||||
|
if(arr1.length !== arr2.length)
|
||||||
|
return false;
|
||||||
|
for(let i = arr1.length; i--;) {
|
||||||
|
if(arr1[i] !== arr2[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
48
app/javascript/components/AppTextField.vue
Normal file
48
app/javascript/components/AppTextField.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<label v-if="label.length" class="label is-small-mobile">{{ label }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea v-if="isTextarea" class="textarea is-small-mobile" :value="value" @input="input"></textarea>
|
||||||
|
<input v-else :type="type" class="input is-small-mobile" :value="value" @input="input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
required: false,
|
||||||
|
type: [String, Number],
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: "text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
isTextarea() {
|
||||||
|
return this.type === "textarea";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
input(evt) {
|
||||||
|
this.$emit("input", evt.target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
21
app/javascript/components/AppValidationErrors.vue
Normal file
21
app/javascript/components/AppValidationErrors.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="notification is-danger" v-if="errors !== null" v-for="(errs, prop) in errors">
|
||||||
|
{{ prop }}: {{ errs.join(", ") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
errors: {
|
||||||
|
required: false,
|
||||||
|
type: Object,
|
||||||
|
default: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
263
app/javascript/components/IngredientEdit.vue
Normal file
263
app/javascript/components/IngredientEdit.vue
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">{{action}} {{ingredient.name || "[Unnamed Ingredient]"}}</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Name</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input is-small-mobile" v-model="ingredient.name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<label class="label is-small-mobile">Nutrient Databank Number</label>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button" :class="{'is-primary': hasNdbn}"><app-icon :icon="hasNdbn ? 'link-intact' : 'link-broken'" size="sm"></app-icon><span>{{ingredient.ndbn}}</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<app-autocomplete
|
||||||
|
:inputClass="'is-small-mobile'"
|
||||||
|
ref="autocomplete"
|
||||||
|
v-model="ingredient.usda_food_name"
|
||||||
|
:minLength="2"
|
||||||
|
valueAttribute="name"
|
||||||
|
labelAttribute="description"
|
||||||
|
placeholder=""
|
||||||
|
@optionSelected="searchItemSelected"
|
||||||
|
:onGetOptions="updateSearchItems"
|
||||||
|
>
|
||||||
|
</app-autocomplete>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasNdbn" class="control">
|
||||||
|
<button type="button" class="button is-danger" @click="removeNdbn">X</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Density</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input is-small-mobile" v-model="ingredient.density">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Notes</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea type="text" class="textarea is-small-mobile" v-model="ingredient.notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
Custom Units
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-body">
|
||||||
|
<button class="button" type="button" @click="addUnit">Add Unit</button>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Grams</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="unit in visibleIngredientUnits" :key="unit.id">
|
||||||
|
<td>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input is-small-mobile" v-model="unit.name">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input is-small-mobile" v-model="unit.gram_weight">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="button is-danger" @click="removeUnit(unit)">X</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
NDBN Units
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-body">
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Grams</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="unit in ingredient.ndbn_units">
|
||||||
|
<td>{{unit.description}}</td>
|
||||||
|
<td>{{unit.gram_weight}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
Nutrition per 100 grams
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="columns is-mobile is-multiline">
|
||||||
|
<div v-for="(nutrient, name) in nutrients" :key="name" class="column is-half-mobile is-one-third-tablet">
|
||||||
|
<label class="label is-small-mobile">{{nutrient.label}}</label>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input type="text" class="input is-small-mobile" :disabled="hasNdbn" v-model="ingredient[name]">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" tabindex="-1" class="unit-label button is-static is-small-mobile">{{nutrient.unit}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ingredient: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
validationErrors: {
|
||||||
|
required: false,
|
||||||
|
type: Object,
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
default: "Editing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
nutrients: {
|
||||||
|
kcal: { label: "Calories", unit: "kcal" },
|
||||||
|
protein: { label: "Protein", unit: "g" },
|
||||||
|
lipids: { label: "Fat", unit: "g" },
|
||||||
|
carbohydrates: { label: "Carbohydrates", unit: "g" },
|
||||||
|
water: { label: "Water", unit: "g" },
|
||||||
|
sugar: { label: "Sugar", unit: "g" },
|
||||||
|
fiber: { label: "Fiber", unit: "g" },
|
||||||
|
cholesterol: { label: "Cholesterol", unit: "mg" },
|
||||||
|
sodium: { label: "Sodium", unit: "mg" },
|
||||||
|
calcium: { label: "Calcium", unit: "mg" },
|
||||||
|
iron: { label: "Iron", unit: "mg" },
|
||||||
|
magnesium: { label: "Magnesium", unit: "mg" },
|
||||||
|
phosphorus: { label: "Phosphorus", unit: "mg" },
|
||||||
|
potassium: { label: "Potassium", unit: "mg" },
|
||||||
|
zinc: { label: "Zinc", unit: "mg" },
|
||||||
|
copper: { label: "Copper", unit: "mg" },
|
||||||
|
manganese: { label: "Manganese", unit: "mg" },
|
||||||
|
vit_a: { label: "Vitamin A", unit: "μg" },
|
||||||
|
vit_b6: { label: "Vitamin B6", unit: "mg" },
|
||||||
|
vit_b12: { label: "Vitamin B12", unit: "μg" },
|
||||||
|
vit_c: { label: "Vitamin C", unit: "mg" },
|
||||||
|
vit_d: { label: "Vitamin D", unit: "μg" },
|
||||||
|
vit_e: { label: "Vitamin E", unit: "mg" },
|
||||||
|
vit_k: { label: "Vitamin K", unit: "μg" },
|
||||||
|
ash: { label: "ash", unit: "g" }
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
visibleIngredientUnits() {
|
||||||
|
return this.ingredient.ingredient_units.filter(iu => iu._destroy !== true);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasNdbn() {
|
||||||
|
return this.ingredient.ndbn !== null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addUnit() {
|
||||||
|
this.ingredient.ingredient_units.push({
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
gram_weight: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeUnit(unit) {
|
||||||
|
if (unit.id) {
|
||||||
|
unit._destroy = true;
|
||||||
|
} else {
|
||||||
|
const idx = this.ingredient.ingredient_units.findIndex(i => i === unit);
|
||||||
|
this.ingredient.ingredient_units.splice(idx, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNdbn() {
|
||||||
|
this.ingredient.ndbn = null;
|
||||||
|
this.ingredient.usda_food_name = null;
|
||||||
|
this.ingredient.ndbn_units = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSearchItems(text) {
|
||||||
|
return api.getUsdaFoodSearch(text)
|
||||||
|
.then(data => data.map(f => {
|
||||||
|
return {
|
||||||
|
name: f.name,
|
||||||
|
ndbn: f.ndbn,
|
||||||
|
description: ["#", f.ndbn, ", Cal:", f.kcal, ", Carbs:", f.carbohydrates, ", Fat:", f.lipid, ", Protein:", f.protein].join("")
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
searchItemSelected(food) {
|
||||||
|
this.ingredient.ndbn = food.ndbn;
|
||||||
|
this.ingredient.usda_food_name = food.name;
|
||||||
|
this.ingredient.ndbn_units = [];
|
||||||
|
|
||||||
|
this.loadResource(
|
||||||
|
api.postIngredientSelectNdbn(this.ingredient)
|
||||||
|
.then(i => Object.assign(this.ingredient, i))
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
.unit-label {
|
||||||
|
width: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
21
app/javascript/components/IngredientShow.vue
Normal file
21
app/javascript/components/IngredientShow.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
{{ingredient.name}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ingredient: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
59
app/javascript/components/LogEdit.vue
Normal file
59
app/javascript/components/LogEdit.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 class="title">Creating Log for {{ log.recipe.name }}</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<app-date-picker v-model="log.date" label="Date"></app-date-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Rating</label>
|
||||||
|
<div class="control">
|
||||||
|
<app-rating v-model="log.rating" :step="1"></app-rating>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-text-field label="Notes" v-model="log.notes" type="textarea"></app-text-field>
|
||||||
|
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<recipe-edit :recipe="log.recipe" :for-logging="true"></recipe-edit>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import RecipeEdit from "./RecipeEdit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
log: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
validationErrors: {
|
||||||
|
required: false,
|
||||||
|
type: Object,
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
67
app/javascript/components/LogShow.vue
Normal file
67
app/javascript/components/LogShow.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">[Log Entry] {{log.recipe.name}}</h1>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half-tablet">
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">Date</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<app-date-time use-input :show-time="false" :date-time="log.date"></app-date-time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">Rating</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<app-rating readonly :value="log.rating"></app-rating>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">Notes</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<textarea readonly class="textarea" :value="log.notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<recipe-show :recipe="log.recipe"></recipe-show>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import RecipeShow from "./RecipeShow";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
log: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
57
app/javascript/components/NoteEdit.vue
Normal file
57
app/javascript/components/NoteEdit.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Note</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea" v-model="note.content"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" :disabled="!canSave" @click="save">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="button is-secondary" @click="cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
note: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
canSave() {
|
||||||
|
return this.note && this.note.content && this.note.content.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.$emit("save", this.note);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.$emit("cancel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
127
app/javascript/components/RecipeEdit.vue
Normal file
127
app/javascript/components/RecipeEdit.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<template v-if="!forLogging">
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<app-text-field label="Name" v-model="recipe.name"></app-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<app-text-field label="Source" v-model="recipe.source"></app-text-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-text-field label="Description" type="textarea" v-model="recipe.description"></app-text-field>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small-mobile">Tags</label>
|
||||||
|
<app-tag-editor v-model="recipe.tags"></app-tag-editor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<app-text-field label="Yields" v-model="recipe.yields"></app-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<app-text-field label="Total Time" type="number" v-model="recipe.total_time"></app-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<app-text-field label="Active Time" v-model="recipe.active_time"></app-text-field>
|
||||||
|
</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 directions-input" v-model="recipe.step_text"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box content" v-html="stepPreview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
forLogging: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
.directions-input {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
214
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
214
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
|
||||||
|
<app-modal wide :open="isBulkEditing" title="Edit Ingredients" @dismiss="cancelBulkEditing">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half bulk-input">
|
||||||
|
<textarea ref="bulkEditTextarea" class="textarea is-size-7-mobile" 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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="button is-primary" @click="addIngredient">Add Ingredient</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
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 = /^\s*(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,|]+?|.+\|)(?:,\s*([^|]*?))?(?:\s*\[(\d+)\]\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 match = line.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const matchedName = match[3].replace(/\|\s*$/, "");
|
||||||
|
let item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(matchedName), preparation: magicFunc(match[4]), id: match[5] || null};
|
||||||
|
parsed.push(item);
|
||||||
|
} else {
|
||||||
|
parsed.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
|
||||||
|
visibleIngredients() {
|
||||||
|
return this.ingredients.filter(i => i._destroy !== true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
createIngredient() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addIngredient() {
|
||||||
|
this.ingredients.push(this.createIngredient());
|
||||||
|
},
|
||||||
|
|
||||||
|
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.indexOf(",") >= 0 ? item.name + "|" : item.name) +
|
||||||
|
(item.preparation ? (", " + item.preparation) : "") +
|
||||||
|
(item.id ? (" [" + item.id + "]") : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bulkEditText = text.join("\n");
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelBulkEditing() {
|
||||||
|
this.isBulkEditing = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveBulkEditing() {
|
||||||
|
const parsed = this.bulkIngredientPreview.filter(i => i !== null);
|
||||||
|
const existing = [...this.ingredients];
|
||||||
|
const newList = [];
|
||||||
|
|
||||||
|
for (let parsedIngredient of parsed) {
|
||||||
|
let newIngredient = null;
|
||||||
|
|
||||||
|
if (parsedIngredient.id !== null) {
|
||||||
|
let intId = parseInt(parsedIngredient.id);
|
||||||
|
let exIdx = existing.findIndex(i => i.id === intId);
|
||||||
|
if (exIdx >= 0) {
|
||||||
|
let ex = existing[exIdx];
|
||||||
|
if (ex.name === parsedIngredient.name) {
|
||||||
|
newIngredient = ex;
|
||||||
|
existing.splice(exIdx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIngredient === null) {
|
||||||
|
newIngredient = this.createIngredient();
|
||||||
|
}
|
||||||
|
|
||||||
|
newIngredient.quantity = parsedIngredient.quantity;
|
||||||
|
newIngredient.units = parsedIngredient.units;
|
||||||
|
newIngredient.name = parsedIngredient.name;
|
||||||
|
newIngredient.preparation = parsedIngredient.preparation;
|
||||||
|
newList.push(newIngredient);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let oldExisting of existing.filter(i => i.id !== null)) {
|
||||||
|
newList.push({id: oldExisting.id, _destroy: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ingredients.splice(0);
|
||||||
|
let sortIdx = 0;
|
||||||
|
for (let n of newList) {
|
||||||
|
n.sort_order = sortIdx++;
|
||||||
|
this.ingredients.push(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBulkEditing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEditIngredientItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
.bulk-input {
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
114
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
114
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="columns is-mobile edit-ingredient-item">
|
||||||
|
<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=""
|
||||||
|
@inputClick="nameClick"
|
||||||
|
@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" @click="deleteIngredient(ingredient)">
|
||||||
|
<app-icon icon="x" size="md"></app-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
nameClick() {
|
||||||
|
if (this.ingredient.ingredient_id === null && this.ingredient.name !== null && this.ingredient.name.length > 2) {
|
||||||
|
this.$refs.autocomplete.updateOptions(this.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: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
@import "../styles/variables";
|
||||||
|
|
||||||
|
.edit-ingredient-item {
|
||||||
|
border-bottom: solid 1px $grey-light;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
238
app/javascript/components/RecipeShow.vue
Normal file
238
app/javascript/components/RecipeShow.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="recipe === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="level is-mobile">
|
||||||
|
|
||||||
|
<div class="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p class="heading">Time</p>
|
||||||
|
<p class="title is-6">{{timeDisplay}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p class="heading">Yields</p>
|
||||||
|
<p class="title is-6">{{recipe.yields}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p class="heading">Source</p>
|
||||||
|
<p class="title is-6">{{recipe.source}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-one-third-desktop">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
Ingredients
|
||||||
|
<button class="button is-small is-primary" type="button" @click="showConvertDialog = true">Convert</button>
|
||||||
|
</div>
|
||||||
|
<div class="message-body content">
|
||||||
|
<ul v-if="recipe.ingredients.length > 0" v-click-strike>
|
||||||
|
<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" v-click-strike>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<app-modal :open="showConvertDialog" @dismiss="showConvertDialog = false" title="Convert Recipe">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Scale</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select v-model="scaleValue">
|
||||||
|
<option v-for="s in scaleOptions" :key="s" :value="s">{{s}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">System</label>
|
||||||
|
<div class="control">
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="" v-model="systemConvertValue" />
|
||||||
|
No System Conversion
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="standard" v-model="systemConvertValue" />
|
||||||
|
Convert to Standard Units
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="metric" v-model="systemConvertValue" />
|
||||||
|
Convert to Metric Units
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Unit</label>
|
||||||
|
<div class="control">
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="" v-model="unitConvertValue" />
|
||||||
|
No Unit Conversion
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="volume" v-model="unitConvertValue" />
|
||||||
|
Convert to Volume Units
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" value="mass" v-model="unitConvertValue" />
|
||||||
|
Convert to Mass Units
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="convert">Convert</button>
|
||||||
|
<button type="button" class="button" @click="showConvertDialog = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</app-modal>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showNutrition: false,
|
||||||
|
showConvertDialog: false,
|
||||||
|
|
||||||
|
scaleValue: '1',
|
||||||
|
systemConvertValue: "",
|
||||||
|
unitConvertValue: "",
|
||||||
|
|
||||||
|
scaleOptions: [
|
||||||
|
'1/4',
|
||||||
|
'1/3',
|
||||||
|
'1/2',
|
||||||
|
'2/3',
|
||||||
|
'3/4',
|
||||||
|
'1',
|
||||||
|
'1 1/2',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
'4'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
timeDisplay() {
|
||||||
|
let a = this.formatMinutes(this.recipe.active_time);
|
||||||
|
const t = this.formatMinutes(this.recipe.total_time);
|
||||||
|
|
||||||
|
if (a) {
|
||||||
|
a = ` (${a} active)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t + a;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
recipe: {
|
||||||
|
handler: function(r) {
|
||||||
|
if (r) {
|
||||||
|
this.scaleValue = r.converted_scale || '1';
|
||||||
|
this.systemConvertValue = r.converted_system;
|
||||||
|
this.unitConvertValue = r.converted_unit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
convert() {
|
||||||
|
this.showConvertDialog = false;
|
||||||
|
this.$router.push({name: 'recipe', query: { scale: this.scaleValue, system: this.systemConvertValue, unit: this.unitConvertValue }});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatMinutes(min) {
|
||||||
|
if (min) {
|
||||||
|
const partUnits = [
|
||||||
|
{unit: "d", minutes: 60 * 24},
|
||||||
|
{unit: "h", minutes: 60},
|
||||||
|
{unit: "m", minutes: 1}
|
||||||
|
];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let remaining = min;
|
||||||
|
|
||||||
|
for (let unit of partUnits) {
|
||||||
|
let val = Math.floor(remaining / unit.minutes);
|
||||||
|
remaining = remaining % unit.minutes;
|
||||||
|
|
||||||
|
if (val > 0) {
|
||||||
|
parts.push(`${val} ${unit.unit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
14
app/javascript/components/The404Page.vue
Normal file
14
app/javascript/components/The404Page.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>404!</h1>
|
||||||
|
<p>WTF?</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
31
app/javascript/components/TheAboutPage.vue
Normal file
31
app/javascript/components/TheAboutPage.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="content">
|
||||||
|
<h1 class="title">About</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A Recipe manager. Source available from my Git repository at <a href="https://source.elbert.us/dan/parsley">https://source.elbert.us</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Parsley is released under the MIT License. All code © Dan Elbert 2018.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Food data provided by:<br/>
|
||||||
|
<cite>
|
||||||
|
US Department of Agriculture, Agricultural Research Service, Nutrient Data Laboratory. USDA National Nutrient Database for Standard Reference, Release 28. Version Current: September 2015. Internet: <a href="http://www.ars.usda.gov/nea/bhnrc/ndl">http://www.ars.usda.gov/nea/bhnrc/ndl</a>
|
||||||
|
</cite>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
15
app/javascript/components/TheAdminUserEditor.vue
Normal file
15
app/javascript/components/TheAdminUserEditor.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
55
app/javascript/components/TheAdminUserList.vue
Normal file
55
app/javascript/components/TheAdminUserList.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 class="title">User List</h1>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Admin?</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in userList" :key="u.id">
|
||||||
|
<td>{{u.name}}</td>
|
||||||
|
<td>{{u.email}}</td>
|
||||||
|
<td>{{u.admin}}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="button is-danger">X</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
userList: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getAdminUserList()
|
||||||
|
.then(list => this.userList = list)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
126
app/javascript/components/TheCalculator.vue
Normal file
126
app/javascript/components/TheCalculator.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">Calculator</h1>
|
||||||
|
|
||||||
|
<div v-for="err in errors" :key="err" class="notification is-warning">
|
||||||
|
{{err}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="field column">
|
||||||
|
<label class="label">Input</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" placeholder="input" v-model="input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field column">
|
||||||
|
<label class="label">Output Unit</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" placeholder="unit" v-model="outputUnit">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="field column">
|
||||||
|
<label class="label">Ingredient</label>
|
||||||
|
<div class="control">
|
||||||
|
<app-autocomplete
|
||||||
|
:inputClass="{'is-success': ingredient !== null}"
|
||||||
|
ref="autocomplete"
|
||||||
|
v-model="ingredient_name"
|
||||||
|
:minLength="2"
|
||||||
|
valueAttribute="name"
|
||||||
|
labelAttribute="name"
|
||||||
|
placeholder=""
|
||||||
|
@optionSelected="searchItemSelected"
|
||||||
|
:onGetOptions="updateSearchItems"
|
||||||
|
>
|
||||||
|
</app-autocomplete>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field column">
|
||||||
|
<label class="label">Density</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" placeholder="8.345 lb/gallon" v-model="density">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Output</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" disabled="disabled" v-model="output">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
input: '',
|
||||||
|
outputUnit: '',
|
||||||
|
ingredient_name: '',
|
||||||
|
ingredient: null,
|
||||||
|
density: '',
|
||||||
|
output: '',
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
updateSearchItems(text) {
|
||||||
|
return api.getSearchIngredients(text);
|
||||||
|
},
|
||||||
|
|
||||||
|
searchItemSelected(ingredient) {
|
||||||
|
this.ingredient = ingredient;
|
||||||
|
this.ingredient_name = ingredient.name;
|
||||||
|
this.density = ingredient.density;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateOutput: debounce(function() {
|
||||||
|
this.loadResource(api.getCalculate(this.input, this.outputUnit, this.density)
|
||||||
|
.then(data => {
|
||||||
|
this.output = data.output;
|
||||||
|
this.errors = data.errors;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'ingredient_name': function(val) {
|
||||||
|
if (this.ingredient && this.ingredient.name !== val) {
|
||||||
|
this.ingredient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
function() {
|
||||||
|
return this.input + this.outputUnit + this.density;
|
||||||
|
},
|
||||||
|
function() {
|
||||||
|
this.updateOutput();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
49
app/javascript/components/TheIngredient.vue
Normal file
49
app/javascript/components/TheIngredient.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="ingredient === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<ingredient-show :ingredient="ingredient"></ingredient-show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_ingredient', params: { id: ingredientId }}">Edit</router-link>
|
||||||
|
<router-link class="button" to="/ingredients">Back</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import IngredientShow from "./IngredientShow";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
ingredient: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
ingredientId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getIngredient(this.ingredientId)
|
||||||
|
.then(data => { this.ingredient = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
IngredientShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
77
app/javascript/components/TheIngredientCreator.vue
Normal file
77
app/javascript/components/TheIngredientCreator.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<ingredient-edit :ingredient="ingredient" :validation-errors="validationErrors" action="Creating"></ingredient-edit>
|
||||||
|
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save</button>
|
||||||
|
<router-link class="button is-secondary" to="/ingredients">Cancel</router-link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import IngredientEdit from "./IngredientEdit";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ingredient: {
|
||||||
|
name: null,
|
||||||
|
notes: null,
|
||||||
|
ndbn: null,
|
||||||
|
density: null,
|
||||||
|
water: null,
|
||||||
|
ash: null,
|
||||||
|
protein: null,
|
||||||
|
kcal: null,
|
||||||
|
fiber: null,
|
||||||
|
sugar: null,
|
||||||
|
carbohydrates: null,
|
||||||
|
calcium: null,
|
||||||
|
iron: null,
|
||||||
|
magnesium: null,
|
||||||
|
phosphorus: null,
|
||||||
|
potassium: null,
|
||||||
|
sodium: null,
|
||||||
|
zinc: null,
|
||||||
|
copper: null,
|
||||||
|
manganese: null,
|
||||||
|
vit_c: null,
|
||||||
|
vit_b6: null,
|
||||||
|
vit_b12: null,
|
||||||
|
vit_a: null,
|
||||||
|
vit_e: null,
|
||||||
|
vit_d: null,
|
||||||
|
vit_k: null,
|
||||||
|
cholesterol: null,
|
||||||
|
lipids: null,
|
||||||
|
ingredient_units: []
|
||||||
|
},
|
||||||
|
validationErrors: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {}
|
||||||
|
this.loadResource(
|
||||||
|
api.postIngredient(this.ingredient)
|
||||||
|
.then(() => this.$router.push('/ingredients'))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
IngredientEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
64
app/javascript/components/TheIngredientEditor.vue
Normal file
64
app/javascript/components/TheIngredientEditor.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div v-if="ingredient === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<ingredient-edit :ingredient="ingredient" :validation-errors="validationErrors"></ingredient-edit>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save</button>
|
||||||
|
<router-link class="button is-secondary" to="/ingredients">Cancel</router-link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import IngredientEdit from "./IngredientEdit";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
ingredient: null,
|
||||||
|
validationErrors: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
ingredientId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {};
|
||||||
|
this.loadResource(
|
||||||
|
api.patchIngredient(this.ingredient)
|
||||||
|
.then(() => this.$router.push({name: 'ingredient', params: {id: this.ingredientId }}))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getIngredient(this.ingredientId)
|
||||||
|
.then(data => { this.ingredient = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
IngredientEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
163
app/javascript/components/TheIngredientList.vue
Normal file
163
app/javascript/components/TheIngredientList.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">Ingredients</h1>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<router-link v-if="isLoggedIn" :to="{name: 'new_ingredient'}" class="button is-primary">Create Ingredient</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="ingredient" @changePage="changePage"></app-pager>
|
||||||
|
|
||||||
|
<table class="table is-fullwidth is-narrow">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>USDA</th>
|
||||||
|
<th>KCal per 100g</th>
|
||||||
|
<th>Density (oz/cup)</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input" placeholder="search names" v-model="search.name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th colspan="4"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="i in ingredients" :key="i.id">
|
||||||
|
<td><router-link :to="{name: 'ingredient', params: { id: i.id } }">{{i.name}}</router-link></td>
|
||||||
|
<td><app-icon v-if="i.usda" icon="check"></app-icon></td>
|
||||||
|
<td>{{i.kcal}}</td>
|
||||||
|
<td>{{i.density}}</td>
|
||||||
|
<td>
|
||||||
|
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_ingredient', params: { id: i.id } }">
|
||||||
|
<app-icon icon="pencil"></app-icon>
|
||||||
|
</router-link>
|
||||||
|
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteIngredient(i)">
|
||||||
|
<app-icon icon="x"></app-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="ingredient" @changePage="changePage"></app-pager>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<router-link v-if="isLoggedIn" :to="{name: 'new_ingredient'}" class="button is-primary">Create Ingredient</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-confirm :open="showConfirmIngredientDelete" :message="confirmIngredientDeleteMessage" :cancel="ingredientDeleteCancel" :confirm="ingredientDeleteConfirm"></app-confirm>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ingredientData: null,
|
||||||
|
ingredientForDeletion: null,
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
per: 25,
|
||||||
|
name: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
ingredients() {
|
||||||
|
if (this.ingredientData) {
|
||||||
|
return this.ingredientData.ingredients;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
totalPages() {
|
||||||
|
if (this.ingredientData) {
|
||||||
|
return this.ingredientData.total_pages
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
currentPage() {
|
||||||
|
if (this.ingredientData) {
|
||||||
|
return this.ingredientData.current_page
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
showConfirmIngredientDelete() {
|
||||||
|
return this.ingredientForDeletion !== null;
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmIngredientDeleteMessage() {
|
||||||
|
if (this.ingredientForDeletion !== null) {
|
||||||
|
return `Are you sure you want to delete ${this.ingredientForDeletion.name}?`;
|
||||||
|
} else {
|
||||||
|
return "??";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changePage(idx) {
|
||||||
|
this.search.page = idx;
|
||||||
|
},
|
||||||
|
|
||||||
|
getList: debounce(function() {
|
||||||
|
return this.loadResource(
|
||||||
|
api.getIngredientList(this.search.page, this.search.per, this.search.name)
|
||||||
|
.then(data => this.ingredientData = data)
|
||||||
|
);
|
||||||
|
}, 500, {leading: true, trailing: true}),
|
||||||
|
|
||||||
|
deleteIngredient(ingredient) {
|
||||||
|
this.ingredientForDeletion = ingredient;
|
||||||
|
},
|
||||||
|
|
||||||
|
ingredientDeleteCancel() {
|
||||||
|
this.ingredientForDeletion = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
ingredientDeleteConfirm() {
|
||||||
|
if (this.ingredientForDeletion !== null) {
|
||||||
|
this.loadResource(
|
||||||
|
api.deleteIngredient(this.ingredientForDeletion.id).then(res => {
|
||||||
|
this.ingredientForDeletion = null;
|
||||||
|
return this.getList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("This is where the thing happens!!");
|
||||||
|
this.ingredientForDeletion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch("search",
|
||||||
|
() => this.getList(),
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
51
app/javascript/components/TheLog.vue
Normal file
51
app/javascript/components/TheLog.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="log === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<log-show :log="log"></log-show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_log', params: { id: logId }}">Edit</router-link>
|
||||||
|
<router-link class="button" to="/logs">Back</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import LogShow from "./LogShow";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
log: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
logId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getLog(this.logId)
|
||||||
|
.then(data => { this.log = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
LogShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
72
app/javascript/components/TheLogCreator.vue
Normal file
72
app/javascript/components/TheLogCreator.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<log-edit v-if="log.recipe !== null" :log="log" :validation-errors="validationErrors">
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save Log</button>
|
||||||
|
<router-link class="button is-secondary" to="/">Cancel</router-link>
|
||||||
|
</div>
|
||||||
|
</log-edit>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save Log</button>
|
||||||
|
<router-link class="button is-secondary" to="/">Cancel</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import LogEdit from "./LogEdit";
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
validationErrors: {},
|
||||||
|
log: {
|
||||||
|
date: null,
|
||||||
|
rating: null,
|
||||||
|
notes: null,
|
||||||
|
recipe: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
recipeId: state => state.route.params.recipeId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.log.original_recipe_id = this.recipeId;
|
||||||
|
this.validationErrors = {};
|
||||||
|
|
||||||
|
this.loadResource(
|
||||||
|
api.postLog(this.log)
|
||||||
|
.then(() => this.$router.push('/'))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getRecipe(this.recipeId, null, null, null, data => this.log.recipe = data)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
LogEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
66
app/javascript/components/TheLogEditor.vue
Normal file
66
app/javascript/components/TheLogEditor.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<log-edit v-if="log !== null" :log="log" :validation-errors="validationErrors">
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save Log</button>
|
||||||
|
<router-link class="button is-secondary" to="/">Cancel</router-link>
|
||||||
|
</div>
|
||||||
|
</log-edit>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="save">Save Log</button>
|
||||||
|
<router-link class="button is-secondary" to="/">Cancel</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from "../lib/Errors";
|
||||||
|
import LogEdit from "./LogEdit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
validationErrors: {},
|
||||||
|
log: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
logId: state => state.route.params.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {};
|
||||||
|
this.loadResource(
|
||||||
|
api.patchLog(this.log)
|
||||||
|
.then(() => this.$router.push('/'))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getLog(this.logId)
|
||||||
|
.then(data => { this.log = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
LogEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
93
app/javascript/components/TheLogList.vue
Normal file
93
app/javascript/components/TheLogList.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">
|
||||||
|
Log Entries
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Recipe</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="l in logs" :key="l.id">
|
||||||
|
<td> <router-link :to="{name: 'log', params: {id: l.id}}">{{l.recipe.name}}</router-link></td>
|
||||||
|
<td><app-date-time :date-time="l.date" :show-time="false"></app-date-time> </td>
|
||||||
|
<td><app-rating :value="l.rating" readonly></app-rating></td>
|
||||||
|
<td>{{l.notes}}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
logData: null,
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
per: 25
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
logs() {
|
||||||
|
if (this.logData) {
|
||||||
|
return this.logData.logs;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
totalPages() {
|
||||||
|
if (this.logData) {
|
||||||
|
return this.logData.total_pages
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
currentPage() {
|
||||||
|
if (this.logData) {
|
||||||
|
return this.logData.current_page
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changePage(idx) {
|
||||||
|
this.search.page = idx;
|
||||||
|
},
|
||||||
|
|
||||||
|
getList: debounce(function() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getLogList(this.search.page, this.search.per)
|
||||||
|
.then(data => this.logData = data)
|
||||||
|
);
|
||||||
|
}, 500, {leading: true, trailing: true})
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch("search",
|
||||||
|
() => this.getList(),
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
98
app/javascript/components/TheNotesList.vue
Normal file
98
app/javascript/components/TheNotesList.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="button is-primary" @click="addNote">Add Note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-modal title="Add Note" :open="editNote !== null" @dismiss="cancelNote">
|
||||||
|
<note-edit v-if="editNote !== null" :note="editNote" @save="saveNote" @cancel="cancelNote"></note-edit>
|
||||||
|
</app-modal>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="n in notes" :key="n.id">
|
||||||
|
<td>
|
||||||
|
{{ n.content }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<app-date-time :date-time="n.created_at"></app-date-time>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="button is-danger" @click="deleteNote(n)">
|
||||||
|
<app-icon icon="x"></app-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import NoteEdit from "./NoteEdit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
notes: [],
|
||||||
|
editNote: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refreshList() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getNoteList()
|
||||||
|
.then(data => this.notes = data)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
addNote() {
|
||||||
|
this.editNote = { id: null, content: "" };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveNote() {
|
||||||
|
this.loadResource(
|
||||||
|
api.postNote(this.editNote)
|
||||||
|
.then(() => {
|
||||||
|
this.editNote = null;
|
||||||
|
return this.refreshList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelNote() {
|
||||||
|
this.editNote = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNote(n) {
|
||||||
|
this.loadResource(
|
||||||
|
api.deleteNote(n)
|
||||||
|
.then(() => {
|
||||||
|
return this.refreshList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.refreshList();
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
NoteEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
79
app/javascript/components/TheRecipe.vue
Normal file
79
app/javascript/components/TheRecipe.vue
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<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>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-if="isScaled" class="tag is-large">{{recipe.converted_scale}} X</span>
|
||||||
|
<span v-if="recipe.converted_system !== null" class="tag is-large">{{recipe.converted_system}}</span>
|
||||||
|
<span v-if="recipe.converted_unit !== null" class="tag is-large">{{recipe.converted_unit}}</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<recipe-show :recipe="recipe"></recipe-show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_recipe', params: { id: recipeId }}">Edit</router-link>
|
||||||
|
<router-link class="button" 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,
|
||||||
|
routeQuery: state => state.route.query,
|
||||||
|
scale: state => state.route.query.scale || null,
|
||||||
|
system: state => state.route.query.system || null,
|
||||||
|
unit: state => state.route.query.unit || null
|
||||||
|
}),
|
||||||
|
|
||||||
|
isScaled() {
|
||||||
|
return this.recipe.converted_scale !== null && this.recipe.converted_scale.length > 0 && this.recipe.converted_scale !== "1";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
routeQuery() {
|
||||||
|
this.refreshData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refreshData() {
|
||||||
|
this.loadResource(
|
||||||
|
api.getRecipe(this.recipeId, this.scale, this.system, this.unit, data => this.recipe = data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.refreshData();
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
59
app/javascript/components/TheRecipeCreator.vue
Normal file
59
app/javascript/components/TheRecipeCreator.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 class="title">Creating {{ recipe.name || "[Unamed Recipe]" }}</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<recipe-edit :recipe="recipe" action="Creating"></recipe-edit>
|
||||||
|
|
||||||
|
<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 api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
validationErrors: {},
|
||||||
|
recipe: {
|
||||||
|
name: null,
|
||||||
|
source: null,
|
||||||
|
description: null,
|
||||||
|
yields: null,
|
||||||
|
total_time: null,
|
||||||
|
active_time: null,
|
||||||
|
step_text: null,
|
||||||
|
tags: [],
|
||||||
|
ingredients: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {};
|
||||||
|
this.loadResource(
|
||||||
|
api.postRecipe(this.recipe)
|
||||||
|
.then(() => this.$router.push('/'))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
67
app/javascript/components/TheRecipeEditor.vue
Normal file
67
app/javascript/components/TheRecipeEditor.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div v-if="recipe === null">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<h1 class="title">Editing {{ recipe.name || "[Unamed Recipe]" }}</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<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.validationErrors = {};
|
||||||
|
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, null, null, null, data => { this.recipe = data; return data; })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecipeEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
216
app/javascript/components/TheRecipeList.vue
Normal file
216
app/javascript/components/TheRecipeList.vue
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="title">Recipes</h1>
|
||||||
|
|
||||||
|
<router-link v-if="isLoggedIn" :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
|
||||||
|
|
||||||
|
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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 === 'asc' ? '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><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>
|
||||||
|
<app-rating v-if="r.rating !== null" :value="r.rating" readonly></app-rating>
|
||||||
|
<span v-else>--</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.yields }}</td>
|
||||||
|
<td>{{ formatRecipeTime(r.total_time, r.active_time) }}</td>
|
||||||
|
<td><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td>
|
||||||
|
<td>
|
||||||
|
<router-link v-if="isLoggedIn" :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary">
|
||||||
|
<app-icon icon="star" size="md"></app-icon>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="isLoggedIn" :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary">
|
||||||
|
<app-icon icon="pencil" size="md"></app-icon>
|
||||||
|
</router-link>
|
||||||
|
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteRecipe(r)">
|
||||||
|
<app-icon icon="x" size="md"></app-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
|
||||||
|
|
||||||
|
<app-confirm :open="showConfirmRecipeDelete" :message="confirmRecipeDeleteMessage" :cancel="recipeDeleteCancel" :confirm="recipeDeleteConfirm"></app-confirm>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
recipeData: null,
|
||||||
|
recipeForDeletion: null,
|
||||||
|
search: {
|
||||||
|
sortColumn: 'created_at',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
page: 1,
|
||||||
|
per: 25,
|
||||||
|
name: null,
|
||||||
|
tags: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
recipes() {
|
||||||
|
if (this.recipeData) {
|
||||||
|
return this.recipeData.recipes;
|
||||||
|
} 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}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
totalPages() {
|
||||||
|
if (this.recipeData) {
|
||||||
|
return this.recipeData.total_pages;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
currentPage() {
|
||||||
|
if (this.recipeData) {
|
||||||
|
return this.recipeData.current_page;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
showConfirmRecipeDelete() {
|
||||||
|
return this.recipeForDeletion !== null;
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmRecipeDeleteMessage() {
|
||||||
|
if (this.showConfirmRecipeDelete) {
|
||||||
|
return `Are you sure you want to delete ${this.recipeForDeletion.name}?`;
|
||||||
|
} else {
|
||||||
|
return "??";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
changePage(idx) {
|
||||||
|
this.search.page = idx;
|
||||||
|
},
|
||||||
|
|
||||||
|
setSort(col) {
|
||||||
|
if (this.search.sortColumn === col) {
|
||||||
|
this.search.sortDirection = this.search.sortDirection === "desc" ? "asc" : "desc";
|
||||||
|
} else {
|
||||||
|
this.search.sortColumn = col;
|
||||||
|
this.search.sortDirection = "asc";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecipe(recipe) {
|
||||||
|
this.recipeForDeletion = recipe;
|
||||||
|
},
|
||||||
|
|
||||||
|
recipeDeleteConfirm() {
|
||||||
|
if (this.recipeForDeletion !== null) {
|
||||||
|
this.loadResource(
|
||||||
|
api.deleteRecipe(this.recipeForDeletion.id).then(() => {
|
||||||
|
this.recipeForDeletion = null;
|
||||||
|
return this.getList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
recipeDeleteCancel() {
|
||||||
|
this.recipeForDeletion = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getList: debounce(function() {
|
||||||
|
return this.loadResource(
|
||||||
|
api.getRecipeList(this.search.page, this.search.per, this.search.sortColumn, this.search.sortDirection, this.search.name, this.search.tags, data => this.recipeData = data)
|
||||||
|
);
|
||||||
|
}, 500, {leading: true, trailing: true}),
|
||||||
|
|
||||||
|
formatRecipeTime(total, active) {
|
||||||
|
let str = "";
|
||||||
|
|
||||||
|
if (total && total > 0) {
|
||||||
|
str += total;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active && active > 0) {
|
||||||
|
if (str.length) {
|
||||||
|
str += " (" + active + ")";
|
||||||
|
} else {
|
||||||
|
str += active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch("search",
|
||||||
|
() => this.getList(),
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
57
app/javascript/components/TheUserCreator.vue
Normal file
57
app/javascript/components/TheUserCreator.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 class="title">Create New User</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<user-edit :user-obj="userObj" action="Creating"></user-edit>
|
||||||
|
|
||||||
|
<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 UserEdit from "./UserEdit";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
validationErrors: {},
|
||||||
|
userObj: {
|
||||||
|
username: '',
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {};
|
||||||
|
this.loadResource(
|
||||||
|
api.postUser(this.userObj)
|
||||||
|
.then(() => this.checkAuthentication())
|
||||||
|
.then(() => this.$router.push('/'))
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
UserEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
77
app/javascript/components/TheUserEditor.vue
Normal file
77
app/javascript/components/TheUserEditor.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 class="title">Edit User</h1>
|
||||||
|
|
||||||
|
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||||
|
|
||||||
|
<user-edit v-if="userObj != null" :user-obj="userObj" action="Creating"></user-edit>
|
||||||
|
|
||||||
|
<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 UserEdit from "./UserEdit";
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import * as Errors from '../lib/Errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
validationErrors: {},
|
||||||
|
userObj: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.refreshUser();
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
user() {
|
||||||
|
this.refreshUser();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refreshUser() {
|
||||||
|
if (this.user) {
|
||||||
|
this.userObj = {
|
||||||
|
username: this.user.username,
|
||||||
|
full_name: this.user.full_name,
|
||||||
|
email: this.user.email,
|
||||||
|
password: '',
|
||||||
|
password_confirmation: ''
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.userObj = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.validationErrors = {};
|
||||||
|
this.loadResource(
|
||||||
|
api.patchUser(this.userObj)
|
||||||
|
.then(() => this.checkAuthentication())
|
||||||
|
.then(() => {
|
||||||
|
this.$router.push('/');
|
||||||
|
})
|
||||||
|
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
UserEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
26
app/javascript/components/UserEdit.vue
Normal file
26
app/javascript/components/UserEdit.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-text-field label="Username" v-model="userObj.username"></app-text-field>
|
||||||
|
<app-text-field label="Name" v-model="userObj.full_name"></app-text-field>
|
||||||
|
<app-text-field label="Email" v-model="userObj.email"></app-text-field>
|
||||||
|
<app-text-field label="Password" v-model="userObj.password"></app-text-field>
|
||||||
|
<app-text-field label="Password Confirmation" v-model="userObj.password_confirmation"></app-text-field>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
userObj: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
100
app/javascript/components/UserLogin.vue
Normal file
100
app/javascript/components/UserLogin.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<button class="button" type="button" @click="openDialog">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<app-modal title="Login" :open="showLogin" @dismiss="showLogin = false">
|
||||||
|
<div>
|
||||||
|
<form @submit.prevent="login">
|
||||||
|
|
||||||
|
<div v-if="error" class="notification is-danger">
|
||||||
|
{{error}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Username</label>
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<input class="input" type="text" placeholder="username" ref="usernameInput" v-model="username">
|
||||||
|
<app-icon icon="person" size="sm" class="is-left"></app-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Password</label>
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<input class="input" type="password" placeholder="password" v-model="password">
|
||||||
|
<app-icon icon="lock-locked" size="sm" class="is-left"></app-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" class="button is-primary" :disabled="!enableSubmit">Login</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-secondary" @click="showLogin = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</app-modal>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import api from "../lib/Api";
|
||||||
|
import { mapMutations } from "vuex";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showLogin: false,
|
||||||
|
error: '',
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
enableSubmit() {
|
||||||
|
return this.username !== '' && this.password !== '' && !this.isLoading;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
...mapMutations([
|
||||||
|
'setUser'
|
||||||
|
]),
|
||||||
|
|
||||||
|
openDialog() {
|
||||||
|
this.showLogin = true;
|
||||||
|
this.$nextTick(() => this.$refs.usernameInput.focus());
|
||||||
|
},
|
||||||
|
|
||||||
|
login() {
|
||||||
|
if (this.username !== '' && this.password !== '') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
4
app/javascript/config.js
Normal file
4
app/javascript/config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export default {
|
||||||
|
baseApiUrl: null
|
||||||
|
}
|
412
app/javascript/lib/Api.js
Normal file
412
app/javascript/lib/Api.js
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
import config from '../config';
|
||||||
|
import * as Errors from './Errors';
|
||||||
|
|
||||||
|
class Api {
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl() { return config.baseApiUrl; }
|
||||||
|
|
||||||
|
url(path) {
|
||||||
|
return this.baseUrl() + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStatus(response) {
|
||||||
|
if (response.status == 204) {
|
||||||
|
return null;
|
||||||
|
} else if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
performRequest(url, method, params = {}, headers = {}) {
|
||||||
|
const hasBody = Object.keys(params || {}).length !== 0;
|
||||||
|
|
||||||
|
const reqHeaders = new Headers();
|
||||||
|
reqHeaders.append('Accept', 'application/json');
|
||||||
|
reqHeaders.append('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
for (let key in headers) {
|
||||||
|
reqHeaders.append(key, headers[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
headers: reqHeaders,
|
||||||
|
method: method,
|
||||||
|
credentials: "same-origin"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasBody) {
|
||||||
|
opts.body = JSON.stringify(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, opts).then(this.checkStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(paramName + "[]=" + (x === null ? '' : encodeURIComponent(x)));
|
||||||
|
}
|
||||||
|
} else if (typeof(val) === "object") {
|
||||||
|
this.objectToUrlParams(val, queryParams, prefixes.concat([key]));
|
||||||
|
} else {
|
||||||
|
queryParams.push(paramName + "=" + (val === null ? '' : encodeURIComponent(val)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildGetUrl(url, params = {}) {
|
||||||
|
const queryParams = this.objectToUrlParams(params);
|
||||||
|
if (queryParams.length) {
|
||||||
|
url = url + "?" + queryParams.join("&");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(url, params = {}) {
|
||||||
|
url = this.buildGetUrl(url, params);
|
||||||
|
|
||||||
|
return this.performRequest(url, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFirstGet(url, params = {}, dataHandler) {
|
||||||
|
url = this.buildGetUrl(url, params);
|
||||||
|
let networkDataReceived = false;
|
||||||
|
|
||||||
|
const networkUpdate = this.performRequest(url, "GET", {}, {"Cache-Then-Network": "true"})
|
||||||
|
.then(data => {
|
||||||
|
networkDataReceived = true;
|
||||||
|
return dataHandler(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return caches.match(url)
|
||||||
|
.then(response => {
|
||||||
|
if (!response) throw Error("No data");
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// don't overwrite newer network data
|
||||||
|
if (!networkDataReceived) {
|
||||||
|
dataHandler(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
// we didn't get cached data, the network is our last hope:
|
||||||
|
return networkUpdate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
post(url, params = {}) {
|
||||||
|
return this.performRequest(url, "POST", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(url, params = {}) {
|
||||||
|
return this.performRequest(url, "PATCH", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
del(url, params = {}) {
|
||||||
|
return this.performRequest(url, "DELETE", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecipeList(page, per, sortColumn, sortDirection, name, tags, dataHandler) {
|
||||||
|
const params = {
|
||||||
|
criteria: {
|
||||||
|
page: page || null,
|
||||||
|
per: per || null,
|
||||||
|
sort_column: sortColumn || null,
|
||||||
|
sort_direction: sortDirection || null,
|
||||||
|
name: name || null,
|
||||||
|
tags: tags || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.cacheFirstGet("/recipes", params, dataHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecipe(id, scale = null, system = null, unit = null, dataHandler) {
|
||||||
|
const params = {
|
||||||
|
scale,
|
||||||
|
system,
|
||||||
|
unit
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.cacheFirstGet("/recipes/" + id, params, dataHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildRecipeParams(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: i.ingredient_id,
|
||||||
|
quantity: i.quantity,
|
||||||
|
units: i.units,
|
||||||
|
preparation: i.preparation,
|
||||||
|
sort_order: i.sort_order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
patchRecipe(recipe) {
|
||||||
|
return this.patch("/recipes/" + recipe.id, this.buildRecipeParams(recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
postRecipe(recipe) {
|
||||||
|
return this.post("/recipes/", this.buildRecipeParams(recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRecipe(id) {
|
||||||
|
return this.del("/recipes/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCalculate(input, output_unit, density) {
|
||||||
|
const params = {
|
||||||
|
input,
|
||||||
|
output_unit,
|
||||||
|
density
|
||||||
|
};
|
||||||
|
return this.get("/calculator/calculate", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIngredientList(page, per, name) {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
per,
|
||||||
|
name
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.get("/ingredients/", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIngredient(id) {
|
||||||
|
return this.get("/ingredients/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildIngredientParams(ingredient) {
|
||||||
|
return {
|
||||||
|
ingredient: {
|
||||||
|
name: ingredient.name,
|
||||||
|
notes: ingredient.notes,
|
||||||
|
ndbn: ingredient.ndbn,
|
||||||
|
density: ingredient.density,
|
||||||
|
|
||||||
|
water: ingredient.water,
|
||||||
|
ash: ingredient.ash,
|
||||||
|
protein: ingredient.protein,
|
||||||
|
kcal: ingredient.kcal,
|
||||||
|
fiber: ingredient.fiber,
|
||||||
|
sugar: ingredient.sugar,
|
||||||
|
carbohydrates: ingredient.carbohydrates,
|
||||||
|
calcium: ingredient.calcium,
|
||||||
|
iron: ingredient.iron,
|
||||||
|
magnesium: ingredient.magnesium,
|
||||||
|
phosphorus: ingredient.phosphorus,
|
||||||
|
potassium: ingredient.potassium,
|
||||||
|
sodium: ingredient.sodium,
|
||||||
|
zinc: ingredient.zinc,
|
||||||
|
copper: ingredient.copper,
|
||||||
|
manganese: ingredient.manganese,
|
||||||
|
vit_c: ingredient.vit_c,
|
||||||
|
vit_b6: ingredient.vit_b6,
|
||||||
|
vit_b12: ingredient.vit_b12,
|
||||||
|
vit_a: ingredient.vit_a,
|
||||||
|
vit_e: ingredient.vit_e,
|
||||||
|
vit_d: ingredient.vit_d,
|
||||||
|
vit_k: ingredient.vit_k,
|
||||||
|
cholesterol: ingredient.cholesterol,
|
||||||
|
lipids: ingredient.lipids,
|
||||||
|
|
||||||
|
|
||||||
|
ingredient_units_attributes: ingredient.ingredient_units.map(iu => {
|
||||||
|
if (iu._destroy) {
|
||||||
|
return {
|
||||||
|
id: iu.id,
|
||||||
|
_destroy: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: iu.id,
|
||||||
|
name: iu.name,
|
||||||
|
gram_weight: iu.gram_weight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postIngredient(ingredient) {
|
||||||
|
return this.post("/ingredients/", this.buildIngredientParams(ingredient));
|
||||||
|
}
|
||||||
|
|
||||||
|
patchIngredient(ingredient) {
|
||||||
|
return this.patch("/ingredients/" + ingredient.id, this.buildIngredientParams(ingredient));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIngredient(id) {
|
||||||
|
return this.del("/ingredients/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
postIngredientSelectNdbn(ingredient) {
|
||||||
|
const url = ingredient.id ? "/ingredients/" + ingredient.id + "/select_ndbn" : "/ingredients/select_ndbn";
|
||||||
|
return this.post(url, this.buildIngredientParams(ingredient));
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsdaFoodSearch(query) {
|
||||||
|
return this.get("/ingredients/usda_food_search", {query: query});
|
||||||
|
}
|
||||||
|
|
||||||
|
getNoteList() {
|
||||||
|
return this.get("/notes/");
|
||||||
|
}
|
||||||
|
|
||||||
|
postNote(note) {
|
||||||
|
const params = {
|
||||||
|
content: note.content
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.post("/notes/", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNote(note) {
|
||||||
|
return this.del("/notes/" + note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogList(page, per) {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
per
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.get("/logs", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLog(id) {
|
||||||
|
return this.get("/logs/" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLogParams(log) {
|
||||||
|
const recParams = this.buildRecipeParams(log.recipe);
|
||||||
|
|
||||||
|
return {
|
||||||
|
log: {
|
||||||
|
date: log.date,
|
||||||
|
rating: log.rating,
|
||||||
|
notes: log.notes,
|
||||||
|
source_recipe_id: log.source_recipe_id,
|
||||||
|
recipe_attributes: recParams.recipe
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
postLog(log) {
|
||||||
|
const params = this.buildLogParams(log);
|
||||||
|
const rec = params.log.recipe_attributes;
|
||||||
|
if (rec && rec.recipe_ingredients_attributes) {
|
||||||
|
rec.recipe_ingredients_attributes.forEach(ri => ri.id = null);
|
||||||
|
}
|
||||||
|
return this.post("/recipes/" + log.original_recipe_id + "/logs/", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
patchLog(log) {
|
||||||
|
return this.patch("/logs/" + log.id, this.buildLogParams(log));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAdminUserList() {
|
||||||
|
return this.get("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
postUser(userObj) {
|
||||||
|
const params = {
|
||||||
|
user: {
|
||||||
|
username: userObj.username,
|
||||||
|
full_name: userObj.full_name,
|
||||||
|
email: userObj.email,
|
||||||
|
password: userObj.password,
|
||||||
|
password_confirmation: userObj.password_confirmation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.post("/user/", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
patchUser(userObj) {
|
||||||
|
const params = {
|
||||||
|
user: {
|
||||||
|
username: userObj.username,
|
||||||
|
full_name: userObj.full_name,
|
||||||
|
email: userObj.email,
|
||||||
|
password: userObj.password,
|
||||||
|
password_confirmation: userObj.password_confirmation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.patch("/user/", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
postLogin(username, password) {
|
||||||
|
const params = {
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.post("/login", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogout() {
|
||||||
|
return this.get("/logout");
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser() {
|
||||||
|
return this.get("/user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = new Api();
|
||||||
|
|
||||||
|
export default api;
|
73
app/javascript/lib/DateTimeUtils.js
Normal file
73
app/javascript/lib/DateTimeUtils.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
|
||||||
|
function zeroPad(val, length) {
|
||||||
|
return val.toString().padStart(length, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the given date is a Date object.
|
||||||
|
function toDate(date) {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
} else if (date === null || date.length === 0) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return new Date(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateObj) {
|
||||||
|
if (dateObj) {
|
||||||
|
return [dateObj.getMonth() + 1, dateObj.getDate(), dateObj.getFullYear() % 100].join("/");
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForEdit(dateObj) {
|
||||||
|
if (dateObj) {
|
||||||
|
return [dateObj.getFullYear(), zeroPad(dateObj.getMonth() + 1, 2), zeroPad(dateObj.getDate(), 2)].join("-");
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(dateObj) {
|
||||||
|
if (dateObj) {
|
||||||
|
return formatDateForEdit(dateObj) + " " + formatTime(dateObj, false);
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateObj, use12hour) {
|
||||||
|
if (dateObj) {
|
||||||
|
let h = dateObj.getHours();
|
||||||
|
const m = zeroPad(dateObj.getMinutes(), 2);
|
||||||
|
let meridiem = "";
|
||||||
|
|
||||||
|
if (use12hour) {
|
||||||
|
meridiem = " am";
|
||||||
|
|
||||||
|
if (h === 0) {
|
||||||
|
h = 12;
|
||||||
|
} else if (h > 12) {
|
||||||
|
h = h - 12;
|
||||||
|
meridiem = " pm";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h = h.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
return h + ":" + m + meridiem;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
toDate,
|
||||||
|
formatDate,
|
||||||
|
formatDateForEdit,
|
||||||
|
formatTimestamp,
|
||||||
|
formatTime
|
||||||
|
}
|
79
app/javascript/lib/Errors.js
Normal file
79
app/javascript/lib/Errors.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
export function ApiError(message, response) {
|
||||||
|
this.message = (message || "Unknown API Error Occurred");
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
ApiError.prototype = Object.assign(new Error(), {
|
||||||
|
name: "ApiError",
|
||||||
|
|
||||||
|
responseCode: function() {
|
||||||
|
if (this.response) {
|
||||||
|
return this.response.status;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ApiServerError(message, response) {
|
||||||
|
this.message = (message || "Unknown API Server Error Occurred");
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
ApiServerError.prototype = Object.assign(new ApiError(), {
|
||||||
|
name: "ApiServerError"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export function ApiNotFoundError(message, response) {
|
||||||
|
this.message = (message || "Unknown API Server Error Occurred");
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
ApiNotFoundError.prototype = Object.assign(new ApiError(), {
|
||||||
|
name: "ApiNotFoundError"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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 = {};
|
||||||
|
if (this.json) {
|
||||||
|
for (let key in this.json) {
|
||||||
|
errors[key] = this.json[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors["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) {
|
||||||
|
return handler(err);
|
||||||
|
} else {
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
69
app/javascript/lib/GlobalMixins.js
Normal file
69
app/javascript/lib/GlobalMixins.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
import Vue from 'vue';
|
||||||
|
import { mapGetters, mapMutations, mapState } from 'vuex';
|
||||||
|
import api from "../lib/Api";
|
||||||
|
|
||||||
|
Vue.mixin({
|
||||||
|
computed: {
|
||||||
|
...mapGetters([
|
||||||
|
"isLoading",
|
||||||
|
"isLoggedIn",
|
||||||
|
"isAdmin"
|
||||||
|
]),
|
||||||
|
...mapState([
|
||||||
|
"user"
|
||||||
|
])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations([
|
||||||
|
'setError',
|
||||||
|
'setLoading',
|
||||||
|
'setUser'
|
||||||
|
]),
|
||||||
|
|
||||||
|
loadResource(promise) {
|
||||||
|
this.setLoading(true);
|
||||||
|
|
||||||
|
return promise
|
||||||
|
.catch(err => this.setError(err))
|
||||||
|
.then(() => this.setLoading(false));
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuthentication() {
|
||||||
|
return this.loadResource(api.getCurrentUser().then(user => this.setUser(user)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function clickStrikeClick(evt) {
|
||||||
|
const isStrikable = el => el && el.tagName === "LI";
|
||||||
|
const strikeClass = "is-strikethrough";
|
||||||
|
|
||||||
|
let t = evt.target;
|
||||||
|
|
||||||
|
while (t !== null && t !== this && !isStrikable(t)) {
|
||||||
|
t = t.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrikable(t)) {
|
||||||
|
const classList = t.className.split(" ");
|
||||||
|
const strIdx = classList.findIndex(c => c === strikeClass);
|
||||||
|
if (strIdx >= 0) {
|
||||||
|
classList.splice(strIdx, 1);
|
||||||
|
} else {
|
||||||
|
classList.push(strikeClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
t.className = classList.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.directive('click-strike', {
|
||||||
|
bind(el) {
|
||||||
|
el.addEventListener("click", clickStrikeClick);
|
||||||
|
},
|
||||||
|
|
||||||
|
unbind(el) {
|
||||||
|
el.removeEventListener("click", clickStrikeClick);
|
||||||
|
}
|
||||||
|
});
|
49
app/javascript/lib/ServiceWorker.js
Normal file
49
app/javascript/lib/ServiceWorker.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
function trackInstall(worker, cb) {
|
||||||
|
worker.addEventListener('statechange', function() {
|
||||||
|
//If the worker is now installed, let the user know that there is an update ready
|
||||||
|
if (worker.state == 'installed') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swUpdate() {
|
||||||
|
navigator.serviceWorker.getRegistration().then(reg => {
|
||||||
|
if (reg && reg.waiting) {
|
||||||
|
reg.waiting.postMessage("skipWaiting");
|
||||||
|
window.location.reload(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swInit(store) {
|
||||||
|
|
||||||
|
const updateReady = () => store.commit("setUpdateAvailable", true);
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(function (reg) {
|
||||||
|
console.log('Registration succeeded. Scope is ' + reg.scope);
|
||||||
|
|
||||||
|
if (reg.waiting) {
|
||||||
|
updateReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's an updated worker installing, track its progress. If it becomes "installed", call
|
||||||
|
// indexController._updateReady()
|
||||||
|
if (reg.installing) {
|
||||||
|
trackInstall(reg.installing, updateReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.addEventListener('updatefound', function () {
|
||||||
|
trackInstall(reg.installing, updateReady);
|
||||||
|
});
|
||||||
|
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.log('Registration failed with ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
69
app/javascript/packs/application.js
Normal file
69
app/javascript/packs/application.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import '../styles';
|
||||||
|
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { sync } from 'vuex-router-sync';
|
||||||
|
import { swInit } from "../lib/ServiceWorker";
|
||||||
|
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';
|
||||||
|
|
||||||
|
import AppAutocomplete from "../components/AppAutocomplete";
|
||||||
|
import AppConfirm from "../components/AppConfirm";
|
||||||
|
import AppDateTime from "../components/AppDateTime";
|
||||||
|
import AppDatePicker from "../components/AppDatePicker";
|
||||||
|
import AppIcon from "../components/AppIcon";
|
||||||
|
import AppModal from "../components/AppModal";
|
||||||
|
import AppNavbar from "../components/AppNavbar";
|
||||||
|
import AppPager from "../components/AppPager";
|
||||||
|
import AppRating from "../components/AppRating";
|
||||||
|
import AppTagEditor from "../components/AppTagEditor";
|
||||||
|
import AppTextField from "../components/AppTextField";
|
||||||
|
import AppValidationErrors from "../components/AppValidationErrors";
|
||||||
|
|
||||||
|
Vue.component("AppAutocomplete", AppAutocomplete);
|
||||||
|
Vue.component("AppConfirm", AppConfirm);
|
||||||
|
Vue.component("AppDateTime", AppDateTime);
|
||||||
|
Vue.component("AppDatePicker", AppDatePicker);
|
||||||
|
Vue.component("AppIcon", AppIcon);
|
||||||
|
Vue.component("AppModal", AppModal);
|
||||||
|
Vue.component("AppNavbar", AppNavbar);
|
||||||
|
Vue.component("AppPager", AppPager);
|
||||||
|
Vue.component("AppRating", AppRating);
|
||||||
|
Vue.component("AppTagEditor", AppTagEditor);
|
||||||
|
Vue.component("AppTextField", AppTextField);
|
||||||
|
Vue.component("AppValidationErrors", AppValidationErrors);
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
swInit(store);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
config.baseApiUrl = app.dataset.url;
|
||||||
|
|
||||||
|
window.$vm = new Vue({
|
||||||
|
el: '#app',
|
||||||
|
store,
|
||||||
|
router,
|
||||||
|
render: createElement => createElement('App'),
|
||||||
|
components: { App }
|
||||||
|
});
|
||||||
|
});
|
156
app/javascript/router.js
Normal file
156
app/javascript/router.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import Router from 'vue-router';
|
||||||
|
|
||||||
|
import The404Page from './components/The404Page';
|
||||||
|
import TheAboutPage from './components/TheAboutPage';
|
||||||
|
import TheCalculator from './components/TheCalculator';
|
||||||
|
|
||||||
|
import TheLog from './components/TheLog';
|
||||||
|
import TheLogList from './components/TheLogList';
|
||||||
|
import TheLogCreator from './components/TheLogCreator';
|
||||||
|
import TheLogEditor from './components/TheLogEditor';
|
||||||
|
|
||||||
|
import TheIngredientList from './components/TheIngredientList';
|
||||||
|
import TheIngredient from "./components/TheIngredient";
|
||||||
|
import TheIngredientEditor from "./components/TheIngredientEditor";
|
||||||
|
import TheIngredientCreator from "./components/TheIngredientCreator";
|
||||||
|
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 TheUserCreator from './components/TheUserCreator';
|
||||||
|
import TheUserEditor from './components/TheUserEditor';
|
||||||
|
|
||||||
|
import TheAdminUserList from './components/TheAdminUserList';
|
||||||
|
import TheAdminUserEditor from './components/TheAdminUserEditor';
|
||||||
|
|
||||||
|
Vue.use(Router);
|
||||||
|
|
||||||
|
const router = new Router({
|
||||||
|
routes: []
|
||||||
|
});
|
||||||
|
|
||||||
|
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: TheRecipeEditor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recipe/:id',
|
||||||
|
name: 'recipe',
|
||||||
|
component: TheRecipe
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/about",
|
||||||
|
name: "about",
|
||||||
|
component: TheAboutPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/calculator",
|
||||||
|
name: "calculator",
|
||||||
|
component: TheCalculator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ingredients",
|
||||||
|
name: "ingredients",
|
||||||
|
component: TheIngredientList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ingredients/new",
|
||||||
|
name: "new_ingredient",
|
||||||
|
component: TheIngredientCreator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ingredients/:id/edit",
|
||||||
|
name: "edit_ingredient",
|
||||||
|
component: TheIngredientEditor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ingredients/:id",
|
||||||
|
name: "ingredient",
|
||||||
|
component: TheIngredient
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/logs",
|
||||||
|
name: "logs",
|
||||||
|
component: TheLogList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/recipes/:recipeId/logs/new",
|
||||||
|
name: "new_log",
|
||||||
|
component: TheLogCreator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/logs/:id/edit",
|
||||||
|
name: "edit_log",
|
||||||
|
component: TheLogEditor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/logs/:id",
|
||||||
|
name: "log",
|
||||||
|
component: TheLog
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/notes",
|
||||||
|
name: "notes",
|
||||||
|
component: TheNotesList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/logout",
|
||||||
|
name: "logout",
|
||||||
|
beforeEnter: (to, from, next) => {
|
||||||
|
const $store = router.app.$store;
|
||||||
|
$store.dispatch("logout")
|
||||||
|
.then(() => next("/"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "/user/new",
|
||||||
|
name: "new_user",
|
||||||
|
component: TheUserCreator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/user/edit",
|
||||||
|
name: "edit_user",
|
||||||
|
component: TheUserEditor
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "/admin/users",
|
||||||
|
name: "admin_users",
|
||||||
|
component: TheAdminUserList
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "/admin/users/:id/edit",
|
||||||
|
name: "admin_edit_user",
|
||||||
|
component: TheAdminUserEditor
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
component: The404Page
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
76
app/javascript/store/index.js
Normal file
76
app/javascript/store/index.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Vuex from 'vuex'
|
||||||
|
|
||||||
|
import api from '../lib/Api';
|
||||||
|
|
||||||
|
Vue.use(Vuex);
|
||||||
|
|
||||||
|
export default new Vuex.Store({
|
||||||
|
strict: process.env.NODE_ENV !== 'production',
|
||||||
|
state: {
|
||||||
|
updateAvailable: false,
|
||||||
|
loadingCount: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
authChecked: false,
|
||||||
|
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) {
|
||||||
|
return state.loading === true;
|
||||||
|
},
|
||||||
|
isLoggedIn(state) {
|
||||||
|
return state.user !== null;
|
||||||
|
},
|
||||||
|
isAdmin(state) {
|
||||||
|
return state.user !== null && state.user.admin === true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
setUpdateAvailable(state, value) {
|
||||||
|
state.updateAvailable = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading(state, value) {
|
||||||
|
if (value) {
|
||||||
|
state.loadingCount = state.loadingCount + 1;
|
||||||
|
} else {
|
||||||
|
state.loadingCount = state.loadingCount - 1;
|
||||||
|
}
|
||||||
|
state.loading = state.loadingCount !== 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
setError(state, value) {
|
||||||
|
console.log(value);
|
||||||
|
state.error = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setUser(state, user) {
|
||||||
|
state.authChecked = true;
|
||||||
|
state.user = user;
|
||||||
|
},
|
||||||
|
|
||||||
|
setMediaQuery(state, data) {
|
||||||
|
state.mediaQueries[data.mediaName] = data.value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
logout({commit}) {
|
||||||
|
return api.getLogout()
|
||||||
|
.then(() => commit("setUser", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
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");
|
||||||
|
}
|
||||||
|
}
|
33
app/javascript/styles/_variables.scss
Normal file
33
app/javascript/styles/_variables.scss
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// coolors.co pallet
|
||||||
|
$coolors-dark: rgba(29, 30, 24, 1);
|
||||||
|
$coolors-blue: rgba(67, 127, 151, 1);
|
||||||
|
$coolors-green: rgba(121, 167, 54, 1);
|
||||||
|
$coolors-red: #ab4c34;
|
||||||
|
$coolors-yellow: rgba(240, 162, 2, 1);
|
||||||
|
|
||||||
|
//$family-sans-serif: "Verdana", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default
|
||||||
|
$family-serif: Georgia, "Times New Roman", Times, serif;
|
||||||
|
|
||||||
|
// Bluma default overrides
|
||||||
|
//$green: #79A736;
|
||||||
|
//$red: #d4424e;
|
||||||
|
//$blue: #9c36a7;
|
||||||
|
|
||||||
|
$green: $coolors-green;
|
||||||
|
$blue: $coolors-blue;
|
||||||
|
$red: $coolors-red;
|
||||||
|
$yellow: $coolors-yellow;
|
||||||
|
$dark: $coolors-dark;
|
||||||
|
|
||||||
|
$primary: $green;
|
||||||
|
$family-primary: $family-serif;
|
||||||
|
|
||||||
|
$modal-content-width: 750px;
|
||||||
|
|
||||||
|
|
||||||
|
// Make all Bulma variables and functions available
|
||||||
|
@import "~bulma/sass/utilities/initial-variables";
|
||||||
|
@import "~bulma/sass/utilities/functions";
|
||||||
|
|
18
app/javascript/styles/_wide_modal.scss
Normal file
18
app/javascript/styles/_wide_modal.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
@include until($desktop) {
|
||||||
|
.modal.is-wide {
|
||||||
|
.modal-content, .modal-card {
|
||||||
|
margin: 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include from($desktop) {
|
||||||
|
.modal.is-wide {
|
||||||
|
.modal-content, .modal-card {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
app/javascript/styles/index.scss
Normal file
49
app/javascript/styles/index.scss
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
@import "./variables";
|
||||||
|
|
||||||
|
@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/components/pagination";
|
||||||
|
@import "~bulma/sass/elements/_all";
|
||||||
|
@import "~bulma/sass/grid/columns";
|
||||||
|
@import "~bulma/sass/layout/section";
|
||||||
|
|
||||||
|
@import "./responsive_controls";
|
||||||
|
@import "./wide_modal";
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: $grey-dark;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
padding-top: 1rem;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title, .subtitle, .navbar, .button, .pagination, .modal-card-title, th {
|
||||||
|
font-family: $family-sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination:not(:last-child) {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user