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/
|
||||
logs/*.*
|
||||
log/*.*
|
||||
db/*.sqlite*
|
||||
tmp/*.*
|
||||
public/assets
|
||||
public/packs
|
||||
node_modules/
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,3 +22,8 @@
|
||||
/public/assets
|
||||
|
||||
.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.
|
||||
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
|
||||
RUN rm -f /etc/service/nginx/down
|
||||
|
||||
@ -21,25 +31,20 @@ ENV RAILS_ENV docker
|
||||
ENV PASSENGER_APP_ENV docker
|
||||
|
||||
# Setup directory and install gems
|
||||
RUN mkdir -p /home/app/parsley/
|
||||
COPY Gemfile /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/
|
||||
RUN mkdir -p /home/app/parsley/log /home/app/parsley/tmp
|
||||
RUN chown -R app:app /home/app/parsley/
|
||||
WORKDIR /home/app/parsley/
|
||||
|
||||
# Set log permissions
|
||||
RUN mkdir -p /home/app/parsley/log
|
||||
RUN chmod 0777 /home/app/parsley/log
|
||||
COPY Gemfile* ./
|
||||
RUN bundle install --deployment --jobs 4 --without development test
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --production=true
|
||||
|
||||
# Copy the app into the image
|
||||
COPY --chown="app" . .
|
||||
|
||||
# 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'
|
||||
|
||||
gem 'rails', '5.0.6'
|
||||
gem 'sqlite3'
|
||||
gem 'pg', '~> 0.21.0'
|
||||
gem 'sass-rails', '~> 5.0'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'rails', '5.2.0'
|
||||
gem 'pg', '~> 1.0.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 '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 'redcarpet', '~> 3.4.0'
|
||||
|
||||
gem 'dalli', '~> 2.7.6'
|
||||
|
||||
# Use ActiveModel has_secure_password
|
||||
gem 'bcrypt', '~> 3.1.11'
|
||||
|
||||
@ -28,11 +24,14 @@ gem 'tzinfo-data'
|
||||
|
||||
group :development, :test do
|
||||
|
||||
gem 'puma', '~> 3.11'
|
||||
gem 'sqlite3'
|
||||
|
||||
gem 'guard', '~> 2.14.0'
|
||||
gem 'guard-rspec', require: false
|
||||
gem 'rspec-rails', '~> 3.5.0'
|
||||
gem 'rspec-rails', '~> 3.7.2'
|
||||
gem 'rails-controller-testing'
|
||||
gem 'factory_girl_rails', '~> 4.8.0'
|
||||
gem 'database_cleaner', '~> 1.5.3'
|
||||
gem 'factory_bot_rails', '~> 4.8.2'
|
||||
gem 'database_cleaner', '~> 1.6.2'
|
||||
end
|
||||
|
||||
|
229
Gemfile.lock
229
Gemfile.lock
@ -1,65 +1,65 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (5.0.6)
|
||||
actionpack (= 5.0.6)
|
||||
nio4r (>= 1.2, < 3.0)
|
||||
websocket-driver (~> 0.6.1)
|
||||
actionmailer (5.0.6)
|
||||
actionpack (= 5.0.6)
|
||||
actionview (= 5.0.6)
|
||||
activejob (= 5.0.6)
|
||||
actioncable (5.2.0)
|
||||
actionpack (= 5.2.0)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailer (5.2.0)
|
||||
actionpack (= 5.2.0)
|
||||
actionview (= 5.2.0)
|
||||
activejob (= 5.2.0)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (5.0.6)
|
||||
actionview (= 5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
actionpack (5.2.0)
|
||||
actionview (= 5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
rack (~> 2.0)
|
||||
rack-test (~> 0.6.3)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
actionview (5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
actionview (5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
builder (~> 3.1)
|
||||
erubis (~> 2.7.0)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.3)
|
||||
activejob (5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
activejob (5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
activerecord (5.0.6)
|
||||
activemodel (= 5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
arel (~> 7.0)
|
||||
activesupport (5.0.6)
|
||||
activemodel (5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
activerecord (5.2.0)
|
||||
activemodel (= 5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
arel (>= 9.0)
|
||||
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)
|
||||
i18n (~> 0.7)
|
||||
i18n (>= 0.7, < 2)
|
||||
minitest (~> 5.1)
|
||||
tzinfo (~> 1.1)
|
||||
arel (7.1.4)
|
||||
autoprefixer-rails (8.1.0.1)
|
||||
execjs
|
||||
bcrypt (3.1.11)
|
||||
bootstrap-sass (3.3.7)
|
||||
autoprefixer-rails (>= 5.2.1)
|
||||
sass (>= 3.3.4)
|
||||
arel (9.0.0)
|
||||
bcrypt (3.1.12)
|
||||
bootsnap (1.3.0)
|
||||
msgpack (~> 1.0)
|
||||
builder (3.2.3)
|
||||
cocoon (1.2.11)
|
||||
coderay (1.1.2)
|
||||
concurrent-ruby (1.0.5)
|
||||
crass (1.0.3)
|
||||
database_cleaner (1.5.3)
|
||||
crass (1.0.4)
|
||||
dalli (2.7.8)
|
||||
database_cleaner (1.6.2)
|
||||
diff-lcs (1.3)
|
||||
erubis (2.7.0)
|
||||
execjs (2.7.0)
|
||||
factory_girl (4.8.1)
|
||||
erubi (1.7.1)
|
||||
factory_bot (4.8.2)
|
||||
activesupport (>= 3.0.0)
|
||||
factory_girl_rails (4.8.0)
|
||||
factory_girl (~> 4.8.0)
|
||||
factory_bot_rails (4.8.2)
|
||||
factory_bot (~> 4.8.2)
|
||||
railties (>= 3.0.0)
|
||||
ffi (1.9.23)
|
||||
ffi (1.9.25)
|
||||
formatador (0.2.5)
|
||||
globalid (0.4.1)
|
||||
activesupport (>= 4.2.0)
|
||||
@ -77,15 +77,11 @@ GEM
|
||||
guard (~> 2.1)
|
||||
guard-compat (~> 1.1)
|
||||
rspec (>= 2.99.0, < 4.0)
|
||||
i18n (0.9.5)
|
||||
i18n (1.0.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jbuilder (2.7.0)
|
||||
activesupport (>= 4.2.0)
|
||||
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)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.1.1)
|
||||
@ -98,52 +94,59 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.1.1)
|
||||
kaminari-core (1.1.1)
|
||||
libv8 (3.16.14.19)
|
||||
liner (0.2.4)
|
||||
listen (3.1.5)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
ruby_dep (~> 1.2)
|
||||
loofah (2.2.1)
|
||||
loofah (2.2.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
lumberjack (1.0.12)
|
||||
lumberjack (1.0.13)
|
||||
mail (2.7.0)
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (0.3.2)
|
||||
mimemagic (~> 0.3.2)
|
||||
memoizable (0.4.2)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
method_source (0.9.0)
|
||||
mimemagic (0.3.2)
|
||||
mini_mime (1.0.0)
|
||||
mini_portile2 (2.3.0)
|
||||
minitest (5.11.3)
|
||||
msgpack (1.2.4)
|
||||
multi_json (1.13.1)
|
||||
nenv (0.3.0)
|
||||
nio4r (2.3.0)
|
||||
nio4r (2.3.1)
|
||||
nokogiri (1.8.2)
|
||||
mini_portile2 (~> 2.3.0)
|
||||
notiffany (0.1.1)
|
||||
nenv (~> 0.1)
|
||||
shellany (~> 0.0)
|
||||
oj (3.6.2)
|
||||
parslet (1.8.2)
|
||||
pg (0.21.0)
|
||||
pg (1.0.0)
|
||||
pry (0.11.3)
|
||||
coderay (~> 1.1.0)
|
||||
method_source (~> 0.9.0)
|
||||
puma (3.11.3)
|
||||
rack (2.0.4)
|
||||
rack-test (0.6.3)
|
||||
rack (>= 1.0)
|
||||
rails (5.0.6)
|
||||
actioncable (= 5.0.6)
|
||||
actionmailer (= 5.0.6)
|
||||
actionpack (= 5.0.6)
|
||||
actionview (= 5.0.6)
|
||||
activejob (= 5.0.6)
|
||||
activemodel (= 5.0.6)
|
||||
activerecord (= 5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
puma (3.11.4)
|
||||
rack (2.0.5)
|
||||
rack-proxy (0.6.4)
|
||||
rack
|
||||
rack-test (1.0.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rails (5.2.0)
|
||||
actioncable (= 5.2.0)
|
||||
actionmailer (= 5.2.0)
|
||||
actionpack (= 5.2.0)
|
||||
actionview (= 5.2.0)
|
||||
activejob (= 5.2.0)
|
||||
activemodel (= 5.2.0)
|
||||
activerecord (= 5.2.0)
|
||||
activestorage (= 5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
bundler (>= 1.3.0)
|
||||
railties (= 5.0.6)
|
||||
railties (= 5.2.0)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-controller-testing (1.0.2)
|
||||
actionpack (~> 5.x, >= 5.0.1)
|
||||
@ -152,53 +155,41 @@ GEM
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.0.3)
|
||||
loofah (~> 2.0)
|
||||
railties (5.0.6)
|
||||
actionpack (= 5.0.6)
|
||||
activesupport (= 5.0.6)
|
||||
rails-html-sanitizer (1.0.4)
|
||||
loofah (~> 2.2, >= 2.2.2)
|
||||
railties (5.2.0)
|
||||
actionpack (= 5.2.0)
|
||||
activesupport (= 5.2.0)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
rake (12.3.0)
|
||||
rake (12.3.1)
|
||||
rb-fsevent (0.10.3)
|
||||
rb-inotify (0.9.10)
|
||||
ffi (>= 0.5.0, < 2)
|
||||
redcarpet (3.4.0)
|
||||
ref (2.0.0)
|
||||
rspec (3.5.0)
|
||||
rspec-core (~> 3.5.0)
|
||||
rspec-expectations (~> 3.5.0)
|
||||
rspec-mocks (~> 3.5.0)
|
||||
rspec-core (3.5.4)
|
||||
rspec-support (~> 3.5.0)
|
||||
rspec-expectations (3.5.0)
|
||||
rspec (3.7.0)
|
||||
rspec-core (~> 3.7.0)
|
||||
rspec-expectations (~> 3.7.0)
|
||||
rspec-mocks (~> 3.7.0)
|
||||
rspec-core (3.7.1)
|
||||
rspec-support (~> 3.7.0)
|
||||
rspec-expectations (3.7.0)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.5.0)
|
||||
rspec-mocks (3.5.0)
|
||||
rspec-support (~> 3.7.0)
|
||||
rspec-mocks (3.7.0)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.5.0)
|
||||
rspec-rails (3.5.2)
|
||||
rspec-support (~> 3.7.0)
|
||||
rspec-rails (3.7.2)
|
||||
actionpack (>= 3.0)
|
||||
activesupport (>= 3.0)
|
||||
railties (>= 3.0)
|
||||
rspec-core (~> 3.5.0)
|
||||
rspec-expectations (~> 3.5.0)
|
||||
rspec-mocks (~> 3.5.0)
|
||||
rspec-support (~> 3.5.0)
|
||||
rspec-support (3.5.0)
|
||||
rspec-core (~> 3.7.0)
|
||||
rspec-expectations (~> 3.7.0)
|
||||
rspec-mocks (~> 3.7.0)
|
||||
rspec-support (~> 3.7.0)
|
||||
rspec-support (3.7.1)
|
||||
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)
|
||||
signed_multiset (0.2.1)
|
||||
sprockets (3.7.1)
|
||||
@ -209,27 +200,22 @@ GEM
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (1.3.13)
|
||||
therubyracer (0.12.3)
|
||||
libv8 (~> 3.16.14.15)
|
||||
ref
|
||||
thor (0.20.0)
|
||||
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)
|
||||
thread_safe (~> 0.1)
|
||||
tzinfo-data (1.2018.3)
|
||||
tzinfo-data (1.2018.5)
|
||||
tzinfo (>= 1.0.0)
|
||||
uglifier (4.1.8)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unitwise (2.2.0)
|
||||
liner (~> 0.2)
|
||||
memoizable (~> 0.4)
|
||||
parslet (~> 1.5)
|
||||
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.3)
|
||||
|
||||
@ -238,28 +224,25 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.11)
|
||||
bootstrap-sass (~> 3.3.7)
|
||||
cocoon (~> 1.2.9)
|
||||
database_cleaner (~> 1.5.3)
|
||||
factory_girl_rails (~> 4.8.0)
|
||||
bootsnap (>= 1.1.0)
|
||||
dalli (~> 2.7.6)
|
||||
database_cleaner (~> 1.6.2)
|
||||
factory_bot_rails (~> 4.8.2)
|
||||
guard (~> 2.14.0)
|
||||
guard-rspec
|
||||
jbuilder (~> 2.7)
|
||||
jquery-rails (~> 4.3.1)
|
||||
kaminari (~> 1.1.1)
|
||||
pg (~> 0.21.0)
|
||||
puma
|
||||
rails (= 5.0.6)
|
||||
oj (~> 3.6.2)
|
||||
pg (~> 1.0.0)
|
||||
puma (~> 3.11)
|
||||
rails (= 5.2.0)
|
||||
rails-controller-testing
|
||||
redcarpet (~> 3.4.0)
|
||||
rspec-rails (~> 3.5.0)
|
||||
sass-rails (~> 5.0)
|
||||
rspec-rails (~> 3.7.2)
|
||||
sqlite3
|
||||
therubyracer
|
||||
turbolinks (~> 5.1.0)
|
||||
tzinfo-data
|
||||
uglifier (>= 1.3.0)
|
||||
unitwise (~> 2.2.0)
|
||||
webpacker (= 3.5.3)
|
||||
|
||||
BUNDLED WITH
|
||||
1.16.1
|
||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
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
|
||||
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.
|
||||
|
||||
Copyright (C) 2016 Dan Elbert
|
||||
Copyright (C) 2018 Dan Elbert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
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
|
||||
// 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 .
|
||||
|
||||
// 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
|
||||
* 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.
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
def verified_request?
|
||||
if request.content_type == "application/json"
|
||||
true
|
||||
else
|
||||
super()
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_valid_user
|
||||
unless current_user?
|
||||
flash[:warning] = "You must login"
|
||||
|
@ -1,4 +1,11 @@
|
||||
class HomeController < ApplicationController
|
||||
|
||||
skip_forgery_protection
|
||||
|
||||
def index
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def about
|
||||
|
||||
end
|
||||
|
@ -1,13 +1,20 @@
|
||||
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.json
|
||||
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
|
||||
|
||||
# GET /ingredients/new
|
||||
@ -32,7 +39,7 @@ class IngredientsController < ApplicationController
|
||||
respond_to do |format|
|
||||
if @ingredient.save
|
||||
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
|
||||
format.html { render :new }
|
||||
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
||||
@ -51,7 +58,7 @@ class IngredientsController < ApplicationController
|
||||
respond_to do |format|
|
||||
if @ingredient.save
|
||||
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
|
||||
format.html { render :edit }
|
||||
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))
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.js {}
|
||||
end
|
||||
render :show
|
||||
end
|
||||
|
||||
def prefetch
|
||||
@ -127,7 +132,7 @@ class IngredientsController < ApplicationController
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
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
|
||||
|
||||
def conversion_params
|
||||
|
@ -2,12 +2,12 @@ class LogsController < ApplicationController
|
||||
|
||||
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 :require_recipe, only: [:new, :create]
|
||||
|
||||
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
|
||||
|
||||
def show
|
||||
@ -21,9 +21,9 @@ class LogsController < ApplicationController
|
||||
def update
|
||||
ensure_owner(@log) do
|
||||
if @log.update(log_params)
|
||||
redirect_to logs_path, notice: 'Log Entry was successfully updated.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :edit
|
||||
render json: @log.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -45,9 +45,9 @@ class LogsController < ApplicationController
|
||||
@log.source_recipe = @recipe
|
||||
|
||||
if @log.save
|
||||
redirect_to logs_path, notice: 'Log Entry was successfully created.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :new
|
||||
render json: @log.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@ -61,7 +61,7 @@ class LogsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_log
|
||||
@log = Log.find(params[:id])
|
||||
@log = Log.includes({recipe: {recipe_ingredients: {ingredient: :ingredient_units} }}).find(params[:id])
|
||||
end
|
||||
|
||||
def set_recipe
|
||||
|
@ -77,6 +77,6 @@ class NotesController < ApplicationController
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def note_params
|
||||
params.require(:note).permit(:user_id, :content)
|
||||
params.require(:note).permit(:content)
|
||||
end
|
||||
end
|
||||
|
@ -6,7 +6,7 @@ class RecipesController < ApplicationController
|
||||
|
||||
# GET /recipes
|
||||
def index
|
||||
@criteria = ViewModels::RecipeCriteria.new(params[:criteria])
|
||||
@criteria = ViewModels::RecipeCriteria.new(criteria_params)
|
||||
@criteria.page = params[:page]
|
||||
@criteria.per = params[:per]
|
||||
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
|
||||
@ -40,26 +40,6 @@ class RecipesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
@recipe = RecipeDecorator.decorate(@recipe, view_context)
|
||||
|
||||
end
|
||||
|
||||
# GET /recipes/1
|
||||
def scale
|
||||
@scale = params[:factor]
|
||||
@recipe.scale(@scale, true)
|
||||
@recipe = RecipeDecorator.decorate(@recipe, view_context)
|
||||
render :show
|
||||
end
|
||||
|
||||
# GET /recipes/new
|
||||
def new
|
||||
@recipe = Recipe.new
|
||||
end
|
||||
|
||||
# GET /recipes/1/edit
|
||||
def edit
|
||||
ensure_owner @recipe
|
||||
end
|
||||
|
||||
# POST /recipes
|
||||
@ -68,9 +48,9 @@ class RecipesController < ApplicationController
|
||||
@recipe.user = current_user
|
||||
|
||||
if @recipe.save
|
||||
redirect_to @recipe, notice: 'Recipe was successfully created.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :new
|
||||
render json: @recipe.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@ -78,23 +58,25 @@ class RecipesController < ApplicationController
|
||||
def update
|
||||
ensure_owner(@recipe) do
|
||||
if @recipe.update(recipe_params)
|
||||
redirect_to @recipe, notice: 'Recipe was successfully updated.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :edit
|
||||
render json: @recipe.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# POST /recipes/preview_steps
|
||||
def preview_steps
|
||||
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
|
||||
end
|
||||
|
||||
# DELETE /recipes/1
|
||||
def destroy
|
||||
ensure_owner(@recipe) do
|
||||
@recipe.deleted = true
|
||||
@recipe.save!(validate: false)
|
||||
|
||||
if @recipe.save(validate: false)
|
||||
redirect_to recipes_url, notice: 'Recipe was successfully destroyed.'
|
||||
else
|
||||
redirect_to recipes_url, error: 'Recipe could not be destroyed.'
|
||||
end
|
||||
render json: { success: true }
|
||||
end
|
||||
end
|
||||
|
||||
@ -108,4 +90,8 @@ class RecipesController < ApplicationController
|
||||
def recipe_params
|
||||
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
|
||||
end
|
||||
|
||||
def criteria_params
|
||||
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
|
||||
end
|
||||
end
|
||||
|
@ -1,6 +1,12 @@
|
||||
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
|
||||
|
||||
@ -9,18 +15,24 @@ class UsersController < ApplicationController
|
||||
def logout
|
||||
set_current_user(nil)
|
||||
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
|
||||
|
||||
def verify_login
|
||||
if user = User.authenticate(params[:username], params[:password])
|
||||
set_current_user(user)
|
||||
flash[:notice] = "Welcome, #{user.display_name}"
|
||||
redirect_to root_path
|
||||
else
|
||||
flash[:error] = "Invalid credentials"
|
||||
render :login
|
||||
|
||||
respond_to do |format|
|
||||
if user = User.authenticate(params[:username], params[:password])
|
||||
set_current_user(user)
|
||||
format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
|
||||
format.json { render json: { success: true, user: { id: user.id, name: user.display_name, admin: user.admin? } } }
|
||||
else
|
||||
format.html { flash[:error] = "Invalid credentials"; render :login }
|
||||
format.json { render json: { success: false, message: 'Invalid Credentials', user: nil } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -31,11 +43,15 @@ class UsersController < ApplicationController
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
if @user.save
|
||||
set_current_user(@user)
|
||||
redirect_to root_path, notice: 'User was successfully created.'
|
||||
else
|
||||
render action: :new
|
||||
respond_to do |format|
|
||||
if @user.save
|
||||
set_current_user(@user)
|
||||
format.html { redirect_to root_path, notice: 'User created.' }
|
||||
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
|
||||
|
||||
@ -45,10 +61,15 @@ class UsersController < ApplicationController
|
||||
|
||||
def update
|
||||
@user = current_user
|
||||
if @user.update(user_params)
|
||||
redirect_to root_path, notice: 'User account updated'
|
||||
else
|
||||
render action: 'edit'
|
||||
|
||||
respond_to do |format|
|
||||
if @user.update(user_params)
|
||||
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
|
||||
|
||||
|
@ -13,57 +13,4 @@ module ApplicationHelper
|
||||
decorated
|
||||
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
|
||||
|
@ -1,11 +1,4 @@
|
||||
module IngredientsHelper
|
||||
|
||||
def ndbn_button_class(ingredient)
|
||||
if ingredient.ndbn.present?
|
||||
'btn btn-success'
|
||||
else
|
||||
'btn btn-default'
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -1,71 +1,3 @@
|
||||
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
|
||||
|
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