Merge branch 'master' into production

This commit is contained in:
Dan Elbert 2018-06-09 13:16:11 -05:00
commit 4c9ef33f20
246 changed files with 18530 additions and 30504 deletions

18
.babelrc Normal file
View 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 }]
]
}

View File

@ -1,5 +1,7 @@
.git/ .git/
logs/*.* log/*.*
db/*.sqlite* db/*.sqlite*
tmp/*.* tmp/*.*
public/assets public/assets
public/packs
node_modules/

5
.gitignore vendored
View File

@ -22,3 +22,8 @@
/public/assets /public/assets
.DS_Store .DS_Store
/public/packs
/public/packs-test
/node_modules
yarn-debug.log*
.yarn-integrity

7
.postcssrc.yml Normal file
View File

@ -0,0 +1,7 @@
plugins:
postcss-import: {}
postcss-cssnext: {
features: {
customProperties: false
}
}

View File

@ -1 +1 @@
2.4.3 2.4.4

View File

@ -3,6 +3,16 @@ FROM phusion/passenger-ruby24:latest
# Use baseimage-docker's init process. # Use baseimage-docker's init process.
CMD ["/sbin/my_init"] CMD ["/sbin/my_init"]
# Install Node
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs yarn && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN gem update --system && gem install bundler
# Enable Nginx / Passenger # Enable Nginx / Passenger
RUN rm -f /etc/service/nginx/down RUN rm -f /etc/service/nginx/down
@ -21,25 +31,20 @@ ENV RAILS_ENV docker
ENV PASSENGER_APP_ENV docker ENV PASSENGER_APP_ENV docker
# Setup directory and install gems # Setup directory and install gems
RUN mkdir -p /home/app/parsley/ RUN mkdir -p /home/app/parsley/log /home/app/parsley/tmp
COPY Gemfile /home/app/parsley/ RUN chown -R app:app /home/app/parsley/
COPY Gemfile.lock /home/app/parsley/
RUN gem install bundler
RUN cd /home/app/parsley/ && bundle install --jobs 4
# Copy the app into the image
COPY . /home/app/parsley/
WORKDIR /home/app/parsley/ WORKDIR /home/app/parsley/
# Set log permissions COPY Gemfile* ./
RUN mkdir -p /home/app/parsley/log RUN bundle install --deployment --jobs 4 --without development test
RUN chmod 0777 /home/app/parsley/log
COPY package.json yarn.lock ./
RUN yarn install --production=true
# Copy the app into the image
COPY --chown="app" . .
# Compile assets # Compile assets
RUN env RAILS_ENV=production bundle exec rake assets:clobber assets:precompile RUN su app -c "bundle exec rails webpacker:clobber webpacker:compile"
# Set ownership of the tmp folder
RUN chown -R app:app /home/app/parsley/tmp
# Clean up APT when done.
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

35
Gemfile
View File

@ -1,26 +1,22 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gem 'rails', '5.0.6' gem 'rails', '5.2.0'
gem 'sqlite3' gem 'pg', '~> 1.0.0'
gem 'pg', '~> 0.21.0'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'puma' gem 'webpacker', '3.5.3'
gem 'bootsnap', '>= 1.1.0', require: false
# See https://github.com/rails/execjs#readme for more supported runtimes
gem 'therubyracer', platforms: :ruby
# Use jquery as the JavaScript library
gem 'jquery-rails', '~> 4.3.1'
gem 'bootstrap-sass', '~> 3.3.7'
gem 'kaminari', '~> 1.1.1'
gem 'turbolinks', '~> 5.1.0'
gem 'jbuilder', '~> 2.7' gem 'jbuilder', '~> 2.7'
gem 'cocoon', '~> 1.2.9' #gem 'jbuilder', git: 'https://github.com/rails/jbuilder', branch: 'master'
gem 'oj', '~> 3.6.2'
gem 'kaminari', '~> 1.1.1'
gem 'unitwise', '~> 2.2.0' gem 'unitwise', '~> 2.2.0'
gem 'redcarpet', '~> 3.4.0' gem 'redcarpet', '~> 3.4.0'
gem 'dalli', '~> 2.7.6'
# Use ActiveModel has_secure_password # Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.11' gem 'bcrypt', '~> 3.1.11'
@ -28,11 +24,14 @@ gem 'tzinfo-data'
group :development, :test do group :development, :test do
gem 'puma', '~> 3.11'
gem 'sqlite3'
gem 'guard', '~> 2.14.0' gem 'guard', '~> 2.14.0'
gem 'guard-rspec', require: false gem 'guard-rspec', require: false
gem 'rspec-rails', '~> 3.5.0' gem 'rspec-rails', '~> 3.7.2'
gem 'rails-controller-testing' gem 'rails-controller-testing'
gem 'factory_girl_rails', '~> 4.8.0' gem 'factory_bot_rails', '~> 4.8.2'
gem 'database_cleaner', '~> 1.5.3' gem 'database_cleaner', '~> 1.6.2'
end end

View File

@ -1,65 +1,65 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.0.6) actioncable (5.2.0)
actionpack (= 5.0.6) actionpack (= 5.2.0)
nio4r (>= 1.2, < 3.0) nio4r (~> 2.0)
websocket-driver (~> 0.6.1) websocket-driver (>= 0.6.1)
actionmailer (5.0.6) actionmailer (5.2.0)
actionpack (= 5.0.6) actionpack (= 5.2.0)
actionview (= 5.0.6) actionview (= 5.2.0)
activejob (= 5.0.6) activejob (= 5.2.0)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.0.6) actionpack (5.2.0)
actionview (= 5.0.6) actionview (= 5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
rack (~> 2.0) rack (~> 2.0)
rack-test (~> 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.0.6) actionview (5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
builder (~> 3.1) builder (~> 3.1)
erubis (~> 2.7.0) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3) rails-html-sanitizer (~> 1.0, >= 1.0.3)
activejob (5.0.6) activejob (5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.0.6) activemodel (5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
activerecord (5.0.6) activerecord (5.2.0)
activemodel (= 5.0.6) activemodel (= 5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
arel (~> 7.0) arel (>= 9.0)
activesupport (5.0.6) activestorage (5.2.0)
actionpack (= 5.2.0)
activerecord (= 5.2.0)
marcel (~> 0.3.1)
activesupport (5.2.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7) i18n (>= 0.7, < 2)
minitest (~> 5.1) minitest (~> 5.1)
tzinfo (~> 1.1) tzinfo (~> 1.1)
arel (7.1.4) arel (9.0.0)
autoprefixer-rails (8.1.0.1) bcrypt (3.1.12)
execjs bootsnap (1.3.0)
bcrypt (3.1.11) msgpack (~> 1.0)
bootstrap-sass (3.3.7)
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
builder (3.2.3) builder (3.2.3)
cocoon (1.2.11)
coderay (1.1.2) coderay (1.1.2)
concurrent-ruby (1.0.5) concurrent-ruby (1.0.5)
crass (1.0.3) crass (1.0.4)
database_cleaner (1.5.3) dalli (2.7.8)
database_cleaner (1.6.2)
diff-lcs (1.3) diff-lcs (1.3)
erubis (2.7.0) erubi (1.7.1)
execjs (2.7.0) factory_bot (4.8.2)
factory_girl (4.8.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
factory_girl_rails (4.8.0) factory_bot_rails (4.8.2)
factory_girl (~> 4.8.0) factory_bot (~> 4.8.2)
railties (>= 3.0.0) railties (>= 3.0.0)
ffi (1.9.23) ffi (1.9.25)
formatador (0.2.5) formatador (0.2.5)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -77,15 +77,11 @@ GEM
guard (~> 2.1) guard (~> 2.1)
guard-compat (~> 1.1) guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0) rspec (>= 2.99.0, < 4.0)
i18n (0.9.5) i18n (1.0.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jbuilder (2.7.0) jbuilder (2.7.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
multi_json (>= 1.2) multi_json (>= 1.2)
jquery-rails (4.3.1)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
kaminari (1.1.1) kaminari (1.1.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.1.1) kaminari-actionview (= 1.1.1)
@ -98,52 +94,59 @@ GEM
activerecord activerecord
kaminari-core (= 1.1.1) kaminari-core (= 1.1.1)
kaminari-core (1.1.1) kaminari-core (1.1.1)
libv8 (3.16.14.19)
liner (0.2.4) liner (0.2.4)
listen (3.1.5) listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4) rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7) rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2) ruby_dep (~> 1.2)
loofah (2.2.1) loofah (2.2.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
lumberjack (1.0.12) lumberjack (1.0.13)
mail (2.7.0) mail (2.7.0)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
marcel (0.3.2)
mimemagic (~> 0.3.2)
memoizable (0.4.2) memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
method_source (0.9.0) method_source (0.9.0)
mimemagic (0.3.2)
mini_mime (1.0.0) mini_mime (1.0.0)
mini_portile2 (2.3.0) mini_portile2 (2.3.0)
minitest (5.11.3) minitest (5.11.3)
msgpack (1.2.4)
multi_json (1.13.1) multi_json (1.13.1)
nenv (0.3.0) nenv (0.3.0)
nio4r (2.3.0) nio4r (2.3.1)
nokogiri (1.8.2) nokogiri (1.8.2)
mini_portile2 (~> 2.3.0) mini_portile2 (~> 2.3.0)
notiffany (0.1.1) notiffany (0.1.1)
nenv (~> 0.1) nenv (~> 0.1)
shellany (~> 0.0) shellany (~> 0.0)
oj (3.6.2)
parslet (1.8.2) parslet (1.8.2)
pg (0.21.0) pg (1.0.0)
pry (0.11.3) pry (0.11.3)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.9.0) method_source (~> 0.9.0)
puma (3.11.3) puma (3.11.4)
rack (2.0.4) rack (2.0.5)
rack-test (0.6.3) rack-proxy (0.6.4)
rack (>= 1.0) rack
rails (5.0.6) rack-test (1.0.0)
actioncable (= 5.0.6) rack (>= 1.0, < 3)
actionmailer (= 5.0.6) rails (5.2.0)
actionpack (= 5.0.6) actioncable (= 5.2.0)
actionview (= 5.0.6) actionmailer (= 5.2.0)
activejob (= 5.0.6) actionpack (= 5.2.0)
activemodel (= 5.0.6) actionview (= 5.2.0)
activerecord (= 5.0.6) activejob (= 5.2.0)
activesupport (= 5.0.6) activemodel (= 5.2.0)
activerecord (= 5.2.0)
activestorage (= 5.2.0)
activesupport (= 5.2.0)
bundler (>= 1.3.0) bundler (>= 1.3.0)
railties (= 5.0.6) railties (= 5.2.0)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2) rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1) actionpack (~> 5.x, >= 5.0.1)
@ -152,53 +155,41 @@ GEM
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.0.3) rails-html-sanitizer (1.0.4)
loofah (~> 2.0) loofah (~> 2.2, >= 2.2.2)
railties (5.0.6) railties (5.2.0)
actionpack (= 5.0.6) actionpack (= 5.2.0)
activesupport (= 5.0.6) activesupport (= 5.2.0)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rake (12.3.0) rake (12.3.1)
rb-fsevent (0.10.3) rb-fsevent (0.10.3)
rb-inotify (0.9.10) rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2) ffi (>= 0.5.0, < 2)
redcarpet (3.4.0) redcarpet (3.4.0)
ref (2.0.0) rspec (3.7.0)
rspec (3.5.0) rspec-core (~> 3.7.0)
rspec-core (~> 3.5.0) rspec-expectations (~> 3.7.0)
rspec-expectations (~> 3.5.0) rspec-mocks (~> 3.7.0)
rspec-mocks (~> 3.5.0) rspec-core (3.7.1)
rspec-core (3.5.4) rspec-support (~> 3.7.0)
rspec-support (~> 3.5.0) rspec-expectations (3.7.0)
rspec-expectations (3.5.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.5.0) rspec-support (~> 3.7.0)
rspec-mocks (3.5.0) rspec-mocks (3.7.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.5.0) rspec-support (~> 3.7.0)
rspec-rails (3.5.2) rspec-rails (3.7.2)
actionpack (>= 3.0) actionpack (>= 3.0)
activesupport (>= 3.0) activesupport (>= 3.0)
railties (>= 3.0) railties (>= 3.0)
rspec-core (~> 3.5.0) rspec-core (~> 3.7.0)
rspec-expectations (~> 3.5.0) rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.5.0) rspec-mocks (~> 3.7.0)
rspec-support (~> 3.5.0) rspec-support (~> 3.7.0)
rspec-support (3.5.0) rspec-support (3.7.1)
ruby_dep (1.5.0) ruby_dep (1.5.0)
sass (3.5.5)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sass-rails (5.0.7)
railties (>= 4.0.0, < 6)
sass (~> 3.1)
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
shellany (0.0.1) shellany (0.0.1)
signed_multiset (0.2.1) signed_multiset (0.2.1)
sprockets (3.7.1) sprockets (3.7.1)
@ -209,27 +200,22 @@ GEM
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (1.3.13) sqlite3 (1.3.13)
therubyracer (0.12.3)
libv8 (~> 3.16.14.15)
ref
thor (0.20.0) thor (0.20.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8)
turbolinks (5.1.0)
turbolinks-source (~> 5.1)
turbolinks-source (5.1.0)
tzinfo (1.2.5) tzinfo (1.2.5)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2018.3) tzinfo-data (1.2018.5)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uglifier (4.1.8)
execjs (>= 0.3.0, < 3)
unitwise (2.2.0) unitwise (2.2.0)
liner (~> 0.2) liner (~> 0.2)
memoizable (~> 0.4) memoizable (~> 0.4)
parslet (~> 1.5) parslet (~> 1.5)
signed_multiset (~> 0.2) signed_multiset (~> 0.2)
websocket-driver (0.6.5) webpacker (3.5.3)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
websocket-driver (0.7.0)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3) websocket-extensions (0.1.3)
@ -238,28 +224,25 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
bcrypt (~> 3.1.11) bcrypt (~> 3.1.11)
bootstrap-sass (~> 3.3.7) bootsnap (>= 1.1.0)
cocoon (~> 1.2.9) dalli (~> 2.7.6)
database_cleaner (~> 1.5.3) database_cleaner (~> 1.6.2)
factory_girl_rails (~> 4.8.0) factory_bot_rails (~> 4.8.2)
guard (~> 2.14.0) guard (~> 2.14.0)
guard-rspec guard-rspec
jbuilder (~> 2.7) jbuilder (~> 2.7)
jquery-rails (~> 4.3.1)
kaminari (~> 1.1.1) kaminari (~> 1.1.1)
pg (~> 0.21.0) oj (~> 3.6.2)
puma pg (~> 1.0.0)
rails (= 5.0.6) puma (~> 3.11)
rails (= 5.2.0)
rails-controller-testing rails-controller-testing
redcarpet (~> 3.4.0) redcarpet (~> 3.4.0)
rspec-rails (~> 3.5.0) rspec-rails (~> 3.7.2)
sass-rails (~> 5.0)
sqlite3 sqlite3
therubyracer
turbolinks (~> 5.1.0)
tzinfo-data tzinfo-data
uglifier (>= 1.3.0)
unitwise (~> 2.2.0) unitwise (~> 2.2.0)
webpacker (= 3.5.3)
BUNDLED WITH BUNDLED WITH
1.16.1 1.16.1

View File

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2016 Dan Elbert Copyright (c) 2018 Dan Elbert
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -5,7 +5,7 @@ A self hosted cookbook
Parsley is released under the MIT License. Parsley is released under the MIT License.
Copyright (C) 2016 Dan Elbert Copyright (C) 2018 Dan Elbert
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@ -10,24 +10,6 @@
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives. // about supported directives.
// //
//= require jquery
//= require jquery_ujs
//= require turbolinks
//= require bootstrap-sprockets
//= require bootstrap-datepicker
//= require bootstrap-tagsinput
//= require cocoon
//= require typeahead
//= require autosize
//= require chosen.jquery
//= require codemirror
//= require markdown
//= require underscore
//= require_tree . //= require_tree .
// Setup star rating automagic
$(document).on("turbolinks:load", function() {
$("input[data-rating='true']").starRating();
});

View File

@ -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);

View File

@ -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);

View File

@ -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("&times;"))
.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);
});
});

View File

@ -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);

View File

@ -1,7 +0,0 @@
(function($) {
$(document).on("turbolinks:load", function() {
$(".log-form input.datepicker").datepicker({autoclose: true, todayBtn: "linked", format: "yyyy-mm-dd"});
});
})(jQuery);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -10,97 +10,4 @@
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new * defined in the other CSS/SCSS files in this directory. It is generally better to create a new
* file per style scope. * file per style scope.
* *
*= require flash_messages
*= require chosen
*= require codemirror
*= require bootstrap-datepicker3
*= require bootstrap-tagsinput
*= require font_references
*/ */
@import "bootstrap-sprockets";
@import "journal_custom_colors";
@import "journal/_variables";
@import "bootstrap";
@import "journal/_bootswatch";
@import "typeahead-bootstrap";
@import "recipes";
@import "star_rating";
@import "codemirror_custom";
// Skin overrides
.has-error {
.help-block,
.control-label,
.radio,
.checkbox,
.radio-inline,
.checkbox-inline,
&.radio label,
&.checkbox label,
&.radio-inline label,
&.checkbox-inline label,
.form-control-feedback {
color: $state-danger-text;
}
.form-control,
.form-control:focus {
border-color: $state-danger-border;
}
}
$footer_height: 40px;
html {
position: relative;
min-height: 100%;
}
body {
/* Margin bottom by footer height */
margin-bottom: $footer_height + 20;
background: image_url("grey_wash_wall.png");
}
#main_container {
background: white;
padding-bottom: 15px;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: $footer_height;
background-color: $gray-lighter;
border-top: solid 1px $gray-light;
}
a.sorted {
position: relative;
}
a.sorted.asc:after {
content: " ";
position: absolute;
margin: 8px 0 0 6px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid black;
}
a.sorted.desc:after {
content: " ";
position: absolute;
margin: 8px 0 0 6px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid black;
}

View File

@ -1,5 +0,0 @@
.CodeMirror {
border: 1px solid $gray-light;
font-family: inconsolata monospace;
font-size: 15px;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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/

View File

@ -1,5 +0,0 @@
$brand-primary: darken(#93C54B, 10%);
$brand-success: $brand-primary;
$text-color: #3E3F3A;

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -3,6 +3,14 @@ class ApplicationController < ActionController::Base
# For APIs, you may want to use :null_session instead. # For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception protect_from_forgery with: :exception
def verified_request?
if request.content_type == "application/json"
true
else
super()
end
end
def ensure_valid_user def ensure_valid_user
unless current_user? unless current_user?
flash[:warning] = "You must login" flash[:warning] = "You must login"

View File

@ -1,4 +1,11 @@
class HomeController < ApplicationController class HomeController < ApplicationController
skip_forgery_protection
def index
render layout: false
end
def about def about
end end

View File

@ -1,13 +1,20 @@
class IngredientsController < ApplicationController class IngredientsController < ApplicationController
before_action :set_ingredient, only: [:edit, :update, :destroy] before_action :set_ingredient, only: [:show, :edit, :update, :destroy]
before_action :ensure_valid_user, except: [:index] before_action :ensure_valid_user, except: [:index, :show]
# GET /ingredients # GET /ingredients
# GET /ingredients.json # GET /ingredients.json
def index def index
@ingredients = Ingredient.all.order(:name) @ingredients = Ingredient.all.order(:name).page(params[:page]).per(params[:per])
if params[:name].present?
@ingredients = @ingredients.matches_tokens(:name, params[:name].split.take(4))
end
end
def show
end end
# GET /ingredients/new # GET /ingredients/new
@ -32,7 +39,7 @@ class IngredientsController < ApplicationController
respond_to do |format| respond_to do |format|
if @ingredient.save if @ingredient.save
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' } format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
format.json { render :show, status: :created, location: @ingredient } format.json { render json: { success: true }, status: :created, location: @ingredient }
else else
format.html { render :new } format.html { render :new }
format.json { render json: @ingredient.errors, status: :unprocessable_entity } format.json { render json: @ingredient.errors, status: :unprocessable_entity }
@ -51,7 +58,7 @@ class IngredientsController < ApplicationController
respond_to do |format| respond_to do |format|
if @ingredient.save if @ingredient.save
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' } format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' }
format.json { render :show, status: :ok, location: @ingredient } format.json { render json: { success: true }, status: :ok, location: @ingredient }
else else
format.html { render :edit } format.html { render :edit }
format.json { render json: @ingredient.errors, status: :unprocessable_entity } format.json { render json: @ingredient.errors, status: :unprocessable_entity }
@ -85,9 +92,7 @@ class IngredientsController < ApplicationController
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn)) @ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
end end
respond_to do |format| render :show
format.js {}
end
end end
def prefetch def prefetch
@ -127,7 +132,7 @@ class IngredientsController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.
def ingredient_params def ingredient_params
params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ingredient_units_attributes => [:name, :gram_weight, :id, :_destroy]) params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ash, :iron, :magnesium, :phosphorus, :potassium, :zinc, :copper, :manganese, :vit_c, :vit_b6, :vit_b12, :vit_a, :vit_e, :vit_d, :cholesterol, :ingredient_units_attributes => [:name, :gram_weight, :id, :_destroy])
end end
def conversion_params def conversion_params

View File

@ -2,12 +2,12 @@ class LogsController < ApplicationController
before_action :ensure_valid_user before_action :ensure_valid_user
before_action :set_log, only: [:show, :edit, :update, :destroy] before_action :set_log, only: [:show, :update, :destroy]
before_action :set_recipe, only: [:new, :create] before_action :set_recipe, only: [:new, :create]
before_action :require_recipe, only: [:new, :create] before_action :require_recipe, only: [:new, :create]
def index def index
@logs = Log.for_user(current_user).order(:date) @logs = Log.for_user(current_user).order(date: :desc).page(params[:page]).per(params[:per])
end end
def show def show
@ -21,9 +21,9 @@ class LogsController < ApplicationController
def update def update
ensure_owner(@log) do ensure_owner(@log) do
if @log.update(log_params) if @log.update(log_params)
redirect_to logs_path, notice: 'Log Entry was successfully updated.' render json: { success: true }
else else
render :edit render json: @log.errors, status: :unprocessable_entity
end end
end end
end end
@ -45,9 +45,9 @@ class LogsController < ApplicationController
@log.source_recipe = @recipe @log.source_recipe = @recipe
if @log.save if @log.save
redirect_to logs_path, notice: 'Log Entry was successfully created.' render json: { success: true }
else else
render :new render json: @log.errors, status: :unprocessable_entity
end end
end end
@ -61,7 +61,7 @@ class LogsController < ApplicationController
private private
def set_log def set_log
@log = Log.find(params[:id]) @log = Log.includes({recipe: {recipe_ingredients: {ingredient: :ingredient_units} }}).find(params[:id])
end end
def set_recipe def set_recipe

View File

@ -77,6 +77,6 @@ class NotesController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through. # Never trust parameters from the scary internet, only allow the white list through.
def note_params def note_params
params.require(:note).permit(:user_id, :content) params.require(:note).permit(:content)
end end
end end

View File

@ -6,7 +6,7 @@ class RecipesController < ApplicationController
# GET /recipes # GET /recipes
def index def index
@criteria = ViewModels::RecipeCriteria.new(params[:criteria]) @criteria = ViewModels::RecipeCriteria.new(criteria_params)
@criteria.page = params[:page] @criteria.page = params[:page]
@criteria.per = params[:per] @criteria.per = params[:per]
@recipes = Recipe.for_criteria(@criteria).includes(:tags) @recipes = Recipe.for_criteria(@criteria).includes(:tags)
@ -40,26 +40,6 @@ class RecipesController < ApplicationController
end end
end end
@recipe = RecipeDecorator.decorate(@recipe, view_context)
end
# GET /recipes/1
def scale
@scale = params[:factor]
@recipe.scale(@scale, true)
@recipe = RecipeDecorator.decorate(@recipe, view_context)
render :show
end
# GET /recipes/new
def new
@recipe = Recipe.new
end
# GET /recipes/1/edit
def edit
ensure_owner @recipe
end end
# POST /recipes # POST /recipes
@ -68,9 +48,9 @@ class RecipesController < ApplicationController
@recipe.user = current_user @recipe.user = current_user
if @recipe.save if @recipe.save
redirect_to @recipe, notice: 'Recipe was successfully created.' render json: { success: true }
else else
render :new render json: @recipe.errors, status: :unprocessable_entity
end end
end end
@ -78,23 +58,25 @@ class RecipesController < ApplicationController
def update def update
ensure_owner(@recipe) do ensure_owner(@recipe) do
if @recipe.update(recipe_params) if @recipe.update(recipe_params)
redirect_to @recipe, notice: 'Recipe was successfully updated.' render json: { success: true }
else else
render :edit render json: @recipe.errors, status: :unprocessable_entity
end end
end end
end end
# POST /recipes/preview_steps
def preview_steps
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
end
# DELETE /recipes/1 # DELETE /recipes/1
def destroy def destroy
ensure_owner(@recipe) do ensure_owner(@recipe) do
@recipe.deleted = true @recipe.deleted = true
@recipe.save!(validate: false)
if @recipe.save(validate: false) render json: { success: true }
redirect_to recipes_url, notice: 'Recipe was successfully destroyed.'
else
redirect_to recipes_url, error: 'Recipe could not be destroyed.'
end
end end
end end
@ -108,4 +90,8 @@ class RecipesController < ApplicationController
def recipe_params def recipe_params
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy]) params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
end end
def criteria_params
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
end
end end

View File

@ -1,6 +1,12 @@
class UsersController < ApplicationController class UsersController < ApplicationController
before_action :ensure_valid_user, except: [:login, :verify_login, :new, :create] UserProxy = Struct.new(:user_id)
before_action :ensure_valid_user, except: [:show, :login, :verify_login, :new, :create]
skip_before_action :verify_authenticity_token, only: [:verify_login]
def show
end
def login def login
@ -9,18 +15,24 @@ class UsersController < ApplicationController
def logout def logout
set_current_user(nil) set_current_user(nil)
session.destroy session.destroy
flash[:notice] = "Logged out"
redirect_to root_path respond_to do |format|
format.html { redirect_to root_path, notice: "Logged out" }
format.json { render json: { success: true } }
end
end end
def verify_login def verify_login
if user = User.authenticate(params[:username], params[:password])
set_current_user(user) respond_to do |format|
flash[:notice] = "Welcome, #{user.display_name}" if user = User.authenticate(params[:username], params[:password])
redirect_to root_path set_current_user(user)
else format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
flash[:error] = "Invalid credentials" format.json { render json: { success: true, user: { id: user.id, name: user.display_name, admin: user.admin? } } }
render :login else
format.html { flash[:error] = "Invalid credentials"; render :login }
format.json { render json: { success: false, message: 'Invalid Credentials', user: nil } }
end
end end
end end
@ -31,11 +43,15 @@ class UsersController < ApplicationController
def create def create
@user = User.new(user_params) @user = User.new(user_params)
if @user.save respond_to do |format|
set_current_user(@user) if @user.save
redirect_to root_path, notice: 'User was successfully created.' set_current_user(@user)
else format.html { redirect_to root_path, notice: 'User created.' }
render action: :new format.json { render :show, status: :created, location: @user }
else
format.html { render :new }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end end
end end
@ -45,10 +61,15 @@ class UsersController < ApplicationController
def update def update
@user = current_user @user = current_user
if @user.update(user_params)
redirect_to root_path, notice: 'User account updated' respond_to do |format|
else if @user.update(user_params)
render action: 'edit' format.html { redirect_to root_path, notice: 'User updated.' }
format.json { render :show, status: :created, location: @user }
else
format.html { render :edit }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end end
end end

View File

@ -13,57 +13,4 @@ module ApplicationHelper
decorated decorated
end end
def timestamp(time)
time ? time.strftime('%D %R') : ''
end
def nav_items
nav = [
nav_item('Recipes', recipes_path, 'recipes'),
nav_item('Ingredients', ingredients_path, 'ingredients'),
nav_item('Logs', logs_path, 'logs', current_user?),
nav_item('Calculator', calculator_path, 'calculator'),
nav_item('About', about_path, 'home'),
nav_item('Notes', notes_path, 'notes', current_user?),
nav_item('Admin', admin_users_path, 'admin/users', current_user? && current_user.admin?)
]
nav.compact
end
def profile_nav_items
if current_user
[content_tag('li', class: 'dropdown') do
li_cnt = ''.html_safe
li_cnt << link_to('#', class: 'dropdown-toggle', data: {toggle: 'dropdown'}, role: 'button') do
''.html_safe << "#{current_user.display_name}" << content_tag('span', '', class: 'caret')
end
li_cnt << content_tag('ul', class: 'dropdown-menu') do
''.html_safe << nav_item('Profile', edit_user_path) << nav_item('Logout', logout_path)
end
li_cnt
end]
else
[nav_item('Login', login_path)]
end
end
def nav_item(name, url, controller = nil, visible = true)
!visible ? nil : content_tag('li', link_to(name, url), class: active_for_controller(controller))
end
def active_for_controller(controller)
if current_controller == controller
'active'
else
''
end
end
def current_controller
params[:controller]
end
end end

View File

@ -1,11 +1,4 @@
module IngredientsHelper module IngredientsHelper
def ndbn_button_class(ingredient)
if ingredient.ndbn.present?
'btn btn-success'
else
'btn btn-default'
end
end
end end

View File

@ -1,71 +1,3 @@
module RecipesHelper module RecipesHelper
def recipe_time(recipe)
output = ''.html_safe
if recipe.total_time.present?
output << "#{humanize_seconds(recipe.total_time.to_i.minutes)}"
if recipe.active_time.present?
output << " (#{humanize_seconds(recipe.active_time.to_i.minutes)} active)"
end
elsif recipe.active_time.present?
output << humanize_seconds(recipe.active_time.to_i.minutes)
end
output
end
def humanize_seconds(secs)
[[60, :s], [60, :m], [24, :h], [1000, :d]].map{ |count, name|
if secs > 0
secs, n = secs.divmod(count)
n == 0 ? nil : "#{n.to_i} #{name}"
end
}.compact.reverse.join(' ')
end
def nutrient_row(recipe, nutrients, heading, nutrient_name)
content_tag('tr') do
[
content_tag('td', heading),
recipe.parsed_yield ? content_tag('td', nutrients.send("#{nutrient_name}_per".to_sym, recipe.parsed_yield.number)) : nil,
content_tag('td', nutrients.send("#{nutrient_name}".to_sym))
].compact.join("\n".html_safe).html_safe
end
end
def index_sort_header(text, field, criteria)
uri = URI(request.original_fullpath)
query = Rack::Utils.parse_query(uri.query)
directions = [:asc, :desc]
current_field = criteria.sort_column
current_direction = criteria.sort_direction
field_param = 'criteria[sort_column]'
direction_param = 'criteria[sort_direction]'
if request.get?
is_sorted = current_field == field.to_sym
if is_sorted && directions.include?(current_direction)
direction = (directions.reject { |d| d == current_direction }).first
else
direction = directions.first
end
if is_sorted && direction == :asc
link_class = 'sorted desc'
elsif is_sorted && direction == :desc
link_class = 'sorted asc'
else
link_class = 'sorted'
end
query[field_param.to_s] = field.to_s
query[direction_param.to_s] = direction.to_s
link_to text, "#{uri.path}?#{query.to_query}", class: link_class
else
text
end
end
end end

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&hellip;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&nbsp;</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>

View 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>

View File

@ -0,0 +1,14 @@
<template>
<div>
<h1>404!</h1>
<p>WTF?</p>
</div>
</template>
<script>
export default {
}
</script>

View 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 &copy; 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>

View File

@ -0,0 +1,15 @@
<template>
</template>
<script>
export default {
}
</script>
<style lang="scss" scoped>
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@ -0,0 +1,4 @@
export default {
baseApiUrl: null
}

412
app/javascript/lib/Api.js Normal file
View 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;

View 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
}

View 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);
}
};
}

View 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);
}
});

View 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);
});
}
}

View 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
View 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;

View 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));
}
}
});

View 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");
}
}

View 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";

View 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;
}
}
}

View 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