Compare commits
204 Commits
Author | SHA1 | Date | |
---|---|---|---|
c26d94e504 | |||
659d7405b2 | |||
169dda4d3b | |||
f5e32cf888 | |||
1b6b3135c7 | |||
808c805cde | |||
7ead02ad7e | |||
67c23015ab | |||
0d35f50dbf | |||
a071e6b21e | |||
b957d44aed | |||
f246f71aa9 | |||
bb2e29f25c | |||
552524e78b | |||
2b77bc9fb8 | |||
855b4ac779 | |||
b311a7d7e8 | |||
7f1cf99237 | |||
dd915624a3 | |||
a306f72215 | |||
1a695b795e | |||
64735b5ee5 | |||
e15bd576e6 | |||
66f6b5346d | |||
6dcbb80794 | |||
3b5b95292c | |||
7dfa978c26 | |||
08df218a00 | |||
f18c5a021c | |||
694bf43738 | |||
99d9cd587e | |||
b85c2e1f60 | |||
cf1294cf20 | |||
9cc3150241 | |||
5a5db7d299 | |||
bb6bf1559f | |||
997da180ff | |||
050e8c0a12 | |||
ccb87d7019 | |||
eeb8e84344 | |||
474e417dca | |||
ad9850a8e2 | |||
b79134ef9a | |||
f4fb33c6c3 | |||
6115afca50 | |||
9bcfeae9ef | |||
ab6f7cf5ee | |||
cf09c5f000 | |||
bea495cd8a | |||
123c943637 | |||
e966a778c0 | |||
d90f4f81bf | |||
521c62d09b | |||
c4ef70d0e8 | |||
337d65c298 | |||
2e86208476 | |||
532c9372ea | |||
0c4c5b899b | |||
ffed63e0b3 | |||
128bfcd535 | |||
b802542869 | |||
2fd83a5d3d | |||
47014118c8 | |||
56fe5aae35 | |||
a6cf7c1b16 | |||
eb34393407 | |||
d8720a8a6c | |||
db02888776 | |||
b1e5c22101 | |||
18603dc783 | |||
4b25f753f1 | |||
41117e2e7f | |||
813e54d8c4 | |||
6aa2c8ee4a | |||
9f0422acf8 | |||
8992a4a082 | |||
021c066cf4 | |||
a2f2a05679 | |||
f97c40bbeb | |||
0d37ce2ab9 | |||
10f50cb920 | |||
b7a13019ae | |||
f79a94fbcb | |||
e3f57ac9cd | |||
8f93591395 | |||
8eeed33828 | |||
bc681481ab | |||
7071a7203b | |||
cd802f0d97 | |||
b9c9d0d145 | |||
7ee316f2b4 | |||
6ca63c2a99 | |||
4c9ef33f20 | |||
306e6a85db | |||
71a2ee4673 | |||
46e8c9376f | |||
cd6f408d39 | |||
3c87ee8083 | |||
c3dbefbb4f | |||
91e9e78650 | |||
fcf191e355 | |||
26b4401450 | |||
0957d84ca0 | |||
f58039e3c6 | |||
6f213865f0 | |||
248700778f | |||
642a9b362c | |||
46072422d4 | |||
2da16e334e | |||
63a566d697 | |||
d81818f2d4 | |||
c13b3a09bd | |||
a07f037b8f | |||
1154da1fd1 | |||
d587ed58b7 | |||
4c1e0929f1 | |||
3b1a9246fd | |||
1c4fa37778 | |||
5579876c63 | |||
97eca6d319 | |||
bb223af9ae | |||
98a204ab59 | |||
56d5965dd2 | |||
1c4fbe15de | |||
dea393d514 | |||
ebb823f66e | |||
dc017aa2f1 | |||
69c02fae9e | |||
cf97bda9e0 | |||
c081ae351a | |||
5b7061e464 | |||
69ccbf58a2 | |||
e3f480b476 | |||
ebf10e36b3 | |||
fcb827cc77 | |||
2a5301f5d5 | |||
f560764292 | |||
624ca9ee7a | |||
a11ab12cd8 | |||
afc2793375 | |||
977e0e8d6a | |||
2ea20048d4 | |||
64b8e03ea6 | |||
b09b3eb196 | |||
cc398bd9d8 | |||
45b0a9c7b1 | |||
e758af25b2 | |||
f7ab63e0d3 | |||
92b962e6b9 | |||
0990551cfc | |||
36cbf92df0 | |||
779cdb6173 | |||
087504e539 | |||
2147c1e48c | |||
51a3be23e9 | |||
e6a9e00f82 | |||
72fffcfbca | |||
d18d3a14b0 | |||
78932eb7ee | |||
203ef90cf0 | |||
9c569693b1 | |||
cfa7d95afe | |||
b8e0835741 | |||
4137c9fd7c | |||
37f150e1cc | |||
e42150f883 | |||
8443220a79 | |||
df35dfcf5e | |||
846c8aa294 | |||
c71e356195 | |||
16bc8b562b | |||
2ad4a22184 | |||
30726ec1b8 | |||
c0d43171a4 | |||
8811109351 | |||
72ac0aab3b | |||
80c203b227 | |||
d368ef4a6f | |||
9994560807 | |||
5f20e04b79 | |||
238adc4b61 | |||
a9527c9626 | |||
2f752eae61 | |||
068b01a7c8 | |||
e6182bd6ff | |||
![]() |
7b8d065f20 | ||
![]() |
78dfa9120e | ||
b890665966 | |||
dd493a09d5 | |||
a86c5afae2 | |||
48d296a7c9 | |||
d5082f9c16 | |||
ffb2c92f74 | |||
9cc9fb2c2f | |||
edfd6f12d6 | |||
3ca786281d | |||
422c77b131 | |||
0cc568344e | |||
205a29ce77 | |||
![]() |
ff231ceee0 | ||
![]() |
2dca779294 | ||
![]() |
70e7a8b415 | ||
7f7a81d49a | |||
9f04a65e19 |
@ -1,5 +1,9 @@
|
||||
.git/
|
||||
logs/*.*
|
||||
log/*.*
|
||||
db/*.sqlite*
|
||||
tmp/*.*
|
||||
public/assets
|
||||
public/packs
|
||||
node_modules/
|
||||
.yarn
|
||||
.pnp.*
|
21
.gitignore
vendored
21
.gitignore
vendored
@ -9,7 +9,7 @@
|
||||
|
||||
# Ignore the default SQLite database.
|
||||
/db/*.sqlite3
|
||||
/db/*.sqlite3-journal
|
||||
/db/*.sqlite3*
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
@ -19,4 +19,21 @@
|
||||
/.idea
|
||||
.byebug_history
|
||||
|
||||
/public/assets
|
||||
/public/assets
|
||||
|
||||
.DS_Store
|
||||
/public/packs
|
||||
/public/packs-test
|
||||
/node_modules
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
/dist
|
||||
|
||||
/public/packs
|
||||
/public/packs-test
|
||||
/node_modules
|
||||
/yarn-error.log
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
.yarn
|
||||
.pnp.*
|
@ -1 +1 @@
|
||||
2.3.0
|
||||
3.3.5
|
||||
|
51
Dockerfile
51
Dockerfile
@ -1,43 +1,40 @@
|
||||
FROM phusion/passenger-ruby22:latest
|
||||
FROM ruby:3.3.5-bookworm
|
||||
|
||||
# Use baseimage-docker's init process.
|
||||
CMD ["/sbin/my_init"]
|
||||
RUN curl -sL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt-get update && apt-get dist-upgrade -y && \
|
||||
apt-get install -y \
|
||||
nodejs \
|
||||
nginx && \
|
||||
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Nginx / Passenger
|
||||
RUN rm -f /etc/service/nginx/down
|
||||
RUN gem update --no-document --system && gem install bundler --no-document && corepack enable
|
||||
|
||||
# Install nginx config files
|
||||
RUN rm /etc/nginx/sites-enabled/default
|
||||
ADD docker/nginx_server.conf /etc/nginx/sites-enabled/parsley.conf
|
||||
ADD docker/nginx_env.conf /etc/nginx/main.d/env.conf
|
||||
|
||||
# Add DB Migration Script
|
||||
RUN mkdir -p /etc/my_init.d
|
||||
ADD docker/db_migrate.sh /etc/my_init.d/db_migrate.sh
|
||||
RUN chmod +x /etc/my_init.d/db_migrate.sh
|
||||
# Add scripts
|
||||
ADD docker/bin/* /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/nginx_service.sh /usr/local/bin/rails_service.sh
|
||||
|
||||
# Set Default RAILS_ENV
|
||||
ENV PASSENGER_APP_ENV docker
|
||||
ENV RAILS_ENV docker
|
||||
|
||||
# Setup directory and install gems
|
||||
RUN mkdir -p /home/app/parsley/
|
||||
COPY Gemfile /home/app/parsley/
|
||||
COPY Gemfile.lock /home/app/parsley/
|
||||
RUN cd /home/app/parsley/ && bundle install --deployment
|
||||
RUN mkdir -p /parsley
|
||||
WORKDIR /parsley
|
||||
|
||||
# Copy the app into the image
|
||||
COPY . /home/app/parsley/
|
||||
WORKDIR /home/app/parsley/
|
||||
COPY Gemfile* ./
|
||||
RUN bundle install
|
||||
|
||||
# Set log permissions
|
||||
RUN mkdir -p /home/app/parsley/log
|
||||
RUN chmod 0777 /home/app/parsley/log
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --immutable
|
||||
|
||||
COPY . .
|
||||
|
||||
# Compile assets
|
||||
RUN env RAILS_ENV=production bundle exec rake assets:clobber assets:precompile
|
||||
RUN env RAILS_ENV=production bundle exec rails shakapacker:clobber shakapacker: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/*
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
55
Gemfile
55
Gemfile
@ -1,45 +1,30 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
gem 'rails', '7.2.1'
|
||||
gem 'pg', '~> 1.5.8'
|
||||
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '4.2.5'
|
||||
gem 'sqlite3'
|
||||
gem 'mysql2', '~> 0.3.18'
|
||||
gem 'sass-rails', '~> 5.0'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'shakapacker', '8.0.2'
|
||||
gem 'bootsnap', '>= 1.1.0', require: false
|
||||
|
||||
# See https://github.com/rails/execjs#readme for more supported runtimes
|
||||
gem 'therubyracer', platforms: :ruby
|
||||
gem 'oj', '~> 3.16.6'
|
||||
gem 'csv', '~> 3.3'
|
||||
|
||||
# Use jquery as the JavaScript library
|
||||
gem 'jquery-rails'
|
||||
gem 'bootstrap-sass', '~> 3.3.6'
|
||||
# Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks
|
||||
gem 'turbolinks'
|
||||
gem 'jbuilder', '~> 2.0'
|
||||
gem 'cocoon', '~> 1.2.6'
|
||||
gem 'unitwise', '~> 2.0.0'
|
||||
gem 'kaminari', '~> 1.2.2'
|
||||
gem 'unitwise', '~> 2.3.0'
|
||||
gem 'redcarpet', '~> 3.6.0'
|
||||
|
||||
# Use ActiveModel has_secure_password
|
||||
gem 'bcrypt', '~> 3.1.7'
|
||||
|
||||
# Use Unicorn as the app server
|
||||
# gem 'unicorn'
|
||||
|
||||
# Use Capistrano for deployment
|
||||
# gem 'capistrano-rails', group: :development
|
||||
gem 'dalli', '~> 3.2.8'
|
||||
gem 'puma', '~> 6.4'
|
||||
gem 'bcrypt', '~> 3.1.18'
|
||||
gem 'tzinfo-data'
|
||||
|
||||
group :development, :test do
|
||||
gem 'sqlite3', '~> 2.1.0'
|
||||
|
||||
gem 'rspec-rails', '~> 3.4.0'
|
||||
gem 'factory_girl_rails', '~> 4.5.0'
|
||||
gem 'database_cleaner', '~> 1.5.1'
|
||||
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
|
||||
gem 'byebug'
|
||||
gem 'guard', '~> 2.18.0'
|
||||
gem 'guard-rspec', require: false
|
||||
gem 'rspec-rails', '~> 7.0.1'
|
||||
gem 'rails-controller-testing'
|
||||
gem 'factory_bot_rails', '~> 6.4.3'
|
||||
gem 'database_cleaner', '~> 2.0.2'
|
||||
end
|
||||
|
||||
group :development do
|
||||
# Access an IRB console on exception pages or by using <%= console %> in views
|
||||
gem 'web-console', '~> 2.0'
|
||||
end
|
||||
|
||||
|
487
Gemfile.lock
487
Gemfile.lock
@ -1,209 +1,322 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actionmailer (4.2.5)
|
||||
actionpack (= 4.2.5)
|
||||
actionview (= 4.2.5)
|
||||
activejob (= 4.2.5)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 1.0, >= 1.0.5)
|
||||
actionpack (4.2.5)
|
||||
actionview (= 4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
rack (~> 1.6)
|
||||
rack-test (~> 0.6.2)
|
||||
rails-dom-testing (~> 1.0, >= 1.0.5)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
actionview (4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
actioncable (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
builder (~> 3.1)
|
||||
erubis (~> 2.7.0)
|
||||
rails-dom-testing (~> 1.0, >= 1.0.5)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
activejob (4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
globalid (>= 0.3.0)
|
||||
activemodel (4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
builder (~> 3.1)
|
||||
activerecord (4.2.5)
|
||||
activemodel (= 4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
arel (~> 6.0)
|
||||
activesupport (4.2.5)
|
||||
i18n (~> 0.7)
|
||||
json (~> 1.7, >= 1.7.7)
|
||||
minitest (~> 5.1)
|
||||
thread_safe (~> 0.3, >= 0.3.4)
|
||||
tzinfo (~> 1.1)
|
||||
arel (6.0.3)
|
||||
autoprefixer-rails (6.3.1)
|
||||
execjs
|
||||
json
|
||||
bcrypt (3.1.10)
|
||||
binding_of_caller (0.7.2)
|
||||
debug_inspector (>= 0.0.1)
|
||||
blankslate (3.1.3)
|
||||
bootstrap-sass (3.3.6)
|
||||
autoprefixer-rails (>= 5.2.1)
|
||||
sass (>= 3.3.4)
|
||||
builder (3.2.2)
|
||||
byebug (8.2.1)
|
||||
cocoon (1.2.6)
|
||||
coffee-rails (4.1.1)
|
||||
coffee-script (>= 2.2.0)
|
||||
railties (>= 4.0.0, < 5.1.x)
|
||||
coffee-script (2.4.1)
|
||||
coffee-script-source
|
||||
execjs
|
||||
coffee-script-source (1.10.0)
|
||||
concurrent-ruby (1.0.0)
|
||||
database_cleaner (1.5.1)
|
||||
debug_inspector (0.0.2)
|
||||
diff-lcs (1.2.5)
|
||||
erubis (2.7.0)
|
||||
execjs (2.6.0)
|
||||
factory_girl (4.5.0)
|
||||
activesupport (>= 3.0.0)
|
||||
factory_girl_rails (4.5.0)
|
||||
factory_girl (~> 4.5.0)
|
||||
railties (>= 3.0.0)
|
||||
globalid (0.3.6)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
activerecord (7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
bigdecimal (3.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
dalli (3.2.8)
|
||||
database_cleaner (2.0.2)
|
||||
database_cleaner-active_record (>= 2, < 3)
|
||||
database_cleaner-active_record (2.2.0)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.3.4)
|
||||
diff-lcs (1.5.1)
|
||||
drb (2.2.1)
|
||||
erubi (1.13.0)
|
||||
factory_bot (6.5.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.3)
|
||||
factory_bot (~> 6.4)
|
||||
railties (>= 5.0.0)
|
||||
ffi (1.17.0)
|
||||
formatador (1.1.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
guard (2.18.1)
|
||||
formatador (>= 0.2.4)
|
||||
listen (>= 2.7, < 4.0)
|
||||
lumberjack (>= 1.0.12, < 2.0)
|
||||
nenv (~> 0.1)
|
||||
notiffany (~> 0.0)
|
||||
pry (>= 0.13.0)
|
||||
shellany (~> 0.0)
|
||||
thor (>= 0.18.1)
|
||||
guard-compat (1.2.1)
|
||||
guard-rspec (4.7.3)
|
||||
guard (~> 2.1)
|
||||
guard-compat (~> 1.1)
|
||||
rspec (>= 2.99.0, < 4.0)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
i18n (0.7.0)
|
||||
jbuilder (2.4.0)
|
||||
activesupport (>= 3.0.0, < 5.1)
|
||||
multi_json (~> 1.2)
|
||||
jquery-rails (4.1.0)
|
||||
rails-dom-testing (~> 1.0)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (1.8.3)
|
||||
libv8 (3.16.14.13)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
kaminari-activerecord (= 1.2.2)
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-actionview (1.2.2)
|
||||
actionview
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-activerecord (1.2.2)
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
liner (0.2.4)
|
||||
loofah (2.0.3)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.6.3)
|
||||
mime-types (>= 1.16, < 3)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.1)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lumberjack (1.2.10)
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
memoizable (0.4.2)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
mime-types (2.99)
|
||||
mini_portile2 (2.0.0)
|
||||
minitest (5.8.3)
|
||||
multi_json (1.11.2)
|
||||
mysql2 (0.3.20)
|
||||
nokogiri (1.6.7.1)
|
||||
mini_portile2 (~> 2.0.0.rc2)
|
||||
parslet (1.7.1)
|
||||
blankslate (>= 2.0, <= 4.0)
|
||||
rack (1.6.4)
|
||||
rack-test (0.6.3)
|
||||
rack (>= 1.0)
|
||||
rails (4.2.5)
|
||||
actionmailer (= 4.2.5)
|
||||
actionpack (= 4.2.5)
|
||||
actionview (= 4.2.5)
|
||||
activejob (= 4.2.5)
|
||||
activemodel (= 4.2.5)
|
||||
activerecord (= 4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
bundler (>= 1.3.0, < 2.0)
|
||||
railties (= 4.2.5)
|
||||
sprockets-rails
|
||||
rails-deprecated_sanitizer (1.0.3)
|
||||
activesupport (>= 4.2.0.alpha)
|
||||
rails-dom-testing (1.0.7)
|
||||
activesupport (>= 4.2.0.beta, < 5.0)
|
||||
nokogiri (~> 1.6.0)
|
||||
rails-deprecated_sanitizer (>= 1.0.1)
|
||||
rails-html-sanitizer (1.0.2)
|
||||
loofah (~> 2.0)
|
||||
railties (4.2.5)
|
||||
actionpack (= 4.2.5)
|
||||
activesupport (= 4.2.5)
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
rake (10.5.0)
|
||||
ref (2.0.0)
|
||||
rspec-core (3.4.1)
|
||||
rspec-support (~> 3.4.0)
|
||||
rspec-expectations (3.4.0)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.25.1)
|
||||
msgpack (1.7.2)
|
||||
nenv (0.3.0)
|
||||
net-imap (0.4.16)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
notiffany (0.1.3)
|
||||
nenv (~> 0.1)
|
||||
shellany (~> 0.0)
|
||||
oj (3.16.6)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
ostruct (0.6.0)
|
||||
package_json (0.1.0)
|
||||
parslet (2.0.0)
|
||||
pg (1.5.8)
|
||||
pry (0.14.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.7)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.1.0)
|
||||
rack (>= 3)
|
||||
webrick (~> 1.8)
|
||||
rails (7.2.1)
|
||||
actioncable (= 7.2.1)
|
||||
actionmailbox (= 7.2.1)
|
||||
actionmailer (= 7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actiontext (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.2.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
rspec (3.13.0)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.1)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.4.0)
|
||||
rspec-mocks (3.4.1)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.4.0)
|
||||
rspec-rails (3.4.0)
|
||||
actionpack (>= 3.0, < 4.3)
|
||||
activesupport (>= 3.0, < 4.3)
|
||||
railties (>= 3.0, < 4.3)
|
||||
rspec-core (~> 3.4.0)
|
||||
rspec-expectations (~> 3.4.0)
|
||||
rspec-mocks (~> 3.4.0)
|
||||
rspec-support (~> 3.4.0)
|
||||
rspec-support (3.4.1)
|
||||
sass (3.4.21)
|
||||
sass-rails (5.0.4)
|
||||
railties (>= 4.0.0, < 5.0)
|
||||
sass (~> 3.1)
|
||||
sprockets (>= 2.8, < 4.0)
|
||||
sprockets-rails (>= 2.0, < 4.0)
|
||||
tilt (>= 1.1, < 3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.0.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.1)
|
||||
securerandom (0.3.1)
|
||||
semantic_range (3.0.0)
|
||||
shakapacker (8.0.2)
|
||||
activesupport (>= 5.2)
|
||||
package_json
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
shellany (0.0.1)
|
||||
signed_multiset (0.2.1)
|
||||
sprockets (3.5.2)
|
||||
sqlite3 (2.1.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
stringio (3.1.1)
|
||||
thor (1.3.2)
|
||||
thread_safe (0.3.6)
|
||||
timeout (0.4.1)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.0.0)
|
||||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (1.3.11)
|
||||
therubyracer (0.12.2)
|
||||
libv8 (~> 3.16.14.0)
|
||||
ref
|
||||
thor (0.19.1)
|
||||
thread_safe (0.3.5)
|
||||
tilt (2.0.2)
|
||||
turbolinks (2.5.3)
|
||||
coffee-rails
|
||||
tzinfo (1.2.2)
|
||||
thread_safe (~> 0.1)
|
||||
uglifier (2.7.2)
|
||||
execjs (>= 0.3.0)
|
||||
json (>= 1.8.0)
|
||||
unitwise (2.0.0)
|
||||
tzinfo-data (1.2024.2)
|
||||
tzinfo (>= 1.0.0)
|
||||
unitwise (2.3.0)
|
||||
liner (~> 0.2)
|
||||
memoizable (~> 0.4)
|
||||
parslet (~> 1.5)
|
||||
parslet (~> 2.0)
|
||||
signed_multiset (~> 0.2)
|
||||
web-console (2.2.1)
|
||||
activemodel (>= 4.0)
|
||||
binding_of_caller (>= 0.7.2)
|
||||
railties (>= 4.0)
|
||||
sprockets-rails (>= 2.0, < 4.0)
|
||||
useragent (0.16.10)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.6.18)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.7)
|
||||
bootstrap-sass (~> 3.3.6)
|
||||
byebug
|
||||
cocoon (~> 1.2.6)
|
||||
database_cleaner (~> 1.5.1)
|
||||
factory_girl_rails (~> 4.5.0)
|
||||
jbuilder (~> 2.0)
|
||||
jquery-rails
|
||||
mysql2 (~> 0.3.18)
|
||||
rails (= 4.2.5)
|
||||
rspec-rails (~> 3.4.0)
|
||||
sass-rails (~> 5.0)
|
||||
sqlite3
|
||||
therubyracer
|
||||
turbolinks
|
||||
uglifier (>= 1.3.0)
|
||||
unitwise (~> 2.0.0)
|
||||
web-console (~> 2.0)
|
||||
bcrypt (~> 3.1.18)
|
||||
bootsnap (>= 1.1.0)
|
||||
csv (~> 3.3)
|
||||
dalli (~> 3.2.8)
|
||||
database_cleaner (~> 2.0.2)
|
||||
factory_bot_rails (~> 6.4.3)
|
||||
guard (~> 2.18.0)
|
||||
guard-rspec
|
||||
kaminari (~> 1.2.2)
|
||||
oj (~> 3.16.6)
|
||||
pg (~> 1.5.8)
|
||||
puma (~> 6.4)
|
||||
rails (= 7.2.1)
|
||||
rails-controller-testing
|
||||
redcarpet (~> 3.6.0)
|
||||
rspec-rails (~> 7.0.1)
|
||||
shakapacker (= 8.0.2)
|
||||
sqlite3 (~> 2.1.0)
|
||||
tzinfo-data
|
||||
unitwise (~> 2.3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
1.11.2
|
||||
2.5.20
|
||||
|
70
Guardfile
Normal file
70
Guardfile
Normal file
@ -0,0 +1,70 @@
|
||||
# A sample Guardfile
|
||||
# More info at https://github.com/guard/guard#readme
|
||||
|
||||
## Uncomment and set this to only include directories you want to watch
|
||||
# directories %w(app lib config test spec features) \
|
||||
# .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")}
|
||||
|
||||
## Note: if you are using the `directories` clause above and you are not
|
||||
## watching the project directory ('.'), then you will want to move
|
||||
## the Guardfile to a watched dir and symlink it back, e.g.
|
||||
#
|
||||
# $ mkdir config
|
||||
# $ mv Guardfile config/
|
||||
# $ ln -s config/Guardfile .
|
||||
#
|
||||
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
|
||||
|
||||
# Note: The cmd option is now required due to the increasing number of ways
|
||||
# rspec may be run, below are examples of the most common uses.
|
||||
# * bundler: 'bundle exec rspec'
|
||||
# * bundler binstubs: 'bin/rspec'
|
||||
# * spring: 'bin/rspec' (This will use spring if running and you have
|
||||
# installed the spring binstubs per the docs)
|
||||
# * zeus: 'zeus rspec' (requires the server to be started separately)
|
||||
# * 'just' rspec: 'rspec'
|
||||
|
||||
guard :rspec, cmd: "bundle exec rspec" do
|
||||
require "guard/rspec/dsl"
|
||||
dsl = Guard::RSpec::Dsl.new(self)
|
||||
|
||||
# Feel free to open issues for suggestions and improvements
|
||||
|
||||
# RSpec files
|
||||
rspec = dsl.rspec
|
||||
watch(rspec.spec_helper) { rspec.spec_dir }
|
||||
watch(rspec.spec_support) { rspec.spec_dir }
|
||||
watch(rspec.spec_files)
|
||||
|
||||
# Ruby files
|
||||
ruby = dsl.ruby
|
||||
dsl.watch_spec_files_for(ruby.lib_files)
|
||||
|
||||
# Rails files
|
||||
rails = dsl.rails(view_extensions: %w(erb haml slim))
|
||||
dsl.watch_spec_files_for(rails.app_files)
|
||||
dsl.watch_spec_files_for(rails.views)
|
||||
|
||||
watch(rails.controllers) do |m|
|
||||
[
|
||||
rspec.spec.("routing/#{m[1]}_routing"),
|
||||
rspec.spec.("controllers/#{m[1]}_controller"),
|
||||
rspec.spec.("acceptance/#{m[1]}")
|
||||
]
|
||||
end
|
||||
|
||||
# Rails config changes
|
||||
watch(rails.spec_helper) { rspec.spec_dir }
|
||||
watch(rails.routes) { "#{rspec.spec_dir}/routing" }
|
||||
watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
|
||||
|
||||
# Capybara features specs
|
||||
watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") }
|
||||
watch(rails.layouts) { |m| rspec.spec.("features/#{m[1]}") }
|
||||
|
||||
# Turnip features and steps
|
||||
watch(%r{^spec/acceptance/(.+)\.feature$})
|
||||
watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
|
||||
Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
|
||||
end
|
||||
end
|
27
Jenkinsfile
vendored
Normal file
27
Jenkinsfile
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
library('jenkins_build')
|
||||
|
||||
node {
|
||||
main {
|
||||
def dockerImageName = "registry.elbert.us/parsley"
|
||||
def dockerImage
|
||||
|
||||
stage("Checkout") {
|
||||
checkout scm
|
||||
}
|
||||
|
||||
stage("Build") {
|
||||
dockerImage = docker.build("${dockerImageName}:latest")
|
||||
}
|
||||
|
||||
stage("Publish") {
|
||||
dockerImage.push()
|
||||
dockerImage.push(env.JOB_BASE_NAME)
|
||||
}
|
||||
|
||||
if (env.BRANCH_NAME == "production") {
|
||||
stage("Deploy") {
|
||||
remote_deploy("azathoth.thenever", "parsley", "./docker-compose-azathoth.yml")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Dan Elbert
|
||||
Copyright (c) 2024 Dan Elbert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
2
Procfile
Normal file
2
Procfile
Normal file
@ -0,0 +1,2 @@
|
||||
rails: bundle exec rails s -b 0.0.0.0
|
||||
shakapacker: bin/shakapacker-dev-server
|
@ -5,7 +5,7 @@ A self hosted cookbook
|
||||
|
||||
Parsley is released under the MIT License.
|
||||
|
||||
Copyright (C) 2016 Dan Elbert
|
||||
Copyright (C) 2024 Dan Elbert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
BIN
app/.DS_Store
vendored
BIN
app/.DS_Store
vendored
Binary file not shown.
BIN
app/assets/.DS_Store
vendored
BIN
app/assets/.DS_Store
vendored
Binary file not shown.
BIN
app/assets/images/.DS_Store
vendored
BIN
app/assets/images/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
@ -10,12 +10,6 @@
|
||||
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
|
||||
// about supported directives.
|
||||
//
|
||||
//= require jquery
|
||||
//= require jquery_ujs
|
||||
//= require turbolinks
|
||||
//= require bootstrap-sprockets
|
||||
//= require cocoon
|
||||
//= require typeahead
|
||||
//= require autosize
|
||||
//= require chosen.jquery
|
||||
|
||||
//= require_tree .
|
||||
|
||||
|
@ -1,46 +0,0 @@
|
||||
|
||||
var flashMessageTypeMap = {
|
||||
error: "danger",
|
||||
notice: "success"
|
||||
};
|
||||
|
||||
function flashMessage(flashType, message) {
|
||||
|
||||
var timeoutIdContainer = {};
|
||||
|
||||
if (flashMessageTypeMap[flashType]) {
|
||||
flashType = flashMessageTypeMap[flashType];
|
||||
}
|
||||
|
||||
var closeButton = $("<button type='button' />")
|
||||
.addClass("close")
|
||||
.append($("<span />").html("×"))
|
||||
.bind("click.Flash", function() { $(this).parent().hide({effect: "fade", duration: 1000}); clearTimeout(timeoutIdContainer.id); });
|
||||
|
||||
var $flashDiv = $("<div></div>")
|
||||
.html(message)
|
||||
.append(closeButton)
|
||||
.addClass("popup")
|
||||
.addClass("alert")
|
||||
.addClass("alert-" + flashType)
|
||||
.hide()
|
||||
.appendTo("#flashContainer")
|
||||
.show({effect: "pulsate", times: 1, duration: 1500});
|
||||
|
||||
timeoutIdContainer.id = setTimeout(function() {
|
||||
$flashDiv.unbind(".Flash");
|
||||
$flashDiv.hide({effect: "fade", duration: 1000});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
$(document).on("ready page:load", function() {
|
||||
$("#flashHolder").find("div").each(function(idx, div) {
|
||||
var $div = $(div);
|
||||
var type = $div.attr("class");
|
||||
var message = $div.html();
|
||||
|
||||
$div.remove();
|
||||
|
||||
flashMessage(type, message);
|
||||
});
|
||||
});
|
@ -1,370 +0,0 @@
|
||||
(function($) {
|
||||
|
||||
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 initializeStepEditor($container) {
|
||||
// $container is either an element that contains many editors, or a single editor.
|
||||
var $editors = $container.find("textarea.step").closest(".step-editor");
|
||||
|
||||
$editors.each(function(idx, elem) {
|
||||
var $editor = $(elem);
|
||||
var $step = $editor.find("textarea.step");
|
||||
autosize($step);
|
||||
|
||||
setTimeout(function() { autosize.update($step); }, 250);
|
||||
});
|
||||
}
|
||||
|
||||
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 $customDensity = $editor.find("input.custom_density");
|
||||
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) {
|
||||
$customDensity.prop('disabled', true);
|
||||
$group.addClass("has-success");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ingredientItemPicked($typeahead, datum) {
|
||||
var $container = $typeahead.closest(".nested-fields");
|
||||
var $ingredientId = $container.find("input.ingredient_id");
|
||||
var $customDensity = $container.find("input.custom_density");
|
||||
var $group = $container.find("div.typeahead-group");
|
||||
|
||||
$ingredientId.val(datum.id);
|
||||
$typeahead.typeahead('val', datum.name);
|
||||
$customDensity.val(datum.density).prop('disabled', true);
|
||||
$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(quantity, units, name, ingredient_id) {
|
||||
$("#ingredient-list").one("cocoon:before-insert", function(e, $container) {
|
||||
var $ingredientId = $container.find("input.ingredient_id");
|
||||
var $name = $container.find("input.custom_name");
|
||||
var $quantity = $container.find("input.quantity");
|
||||
var $units = $container.find("input.units");
|
||||
|
||||
$ingredientId.typeahead("val", name);
|
||||
$name.val(name);
|
||||
$units.val(units);
|
||||
$quantity.val(quantity);
|
||||
});
|
||||
|
||||
$("#addIngredientButton").trigger("click");
|
||||
}
|
||||
|
||||
function addStep(step) {
|
||||
$("#step-list").one("cocoon:before-insert", function(e, $container) {
|
||||
var $step = $container.find("textarea.step");
|
||||
$step.val(step);
|
||||
});
|
||||
|
||||
$("#addStepButton").trigger("click");
|
||||
}
|
||||
|
||||
$(document).on("ready page:load", function() {
|
||||
|
||||
var ingredientSearchEngine = new Bloodhound({
|
||||
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 $stepList = $("#step-list");
|
||||
|
||||
initializeStepEditor($stepList);
|
||||
|
||||
$stepList
|
||||
.on("cocoon:after-insert", function(e, item) {
|
||||
reorder($(this));
|
||||
initializeStepEditor(item);
|
||||
})
|
||||
.on("cocoon:after-remove", function(e, item) {
|
||||
reorder($(this));
|
||||
})
|
||||
.on('changed', 'input.sort_order', function() {
|
||||
var $this = $(this);
|
||||
var $span = $this.closest(".nested-fields").find(".sort-order-display");
|
||||
$span.html($this.val());
|
||||
});
|
||||
|
||||
var $ingredientList = $("#ingredient-list");
|
||||
|
||||
initializeIngredientEditor($ingredientList, ingredientSearchEngine);
|
||||
|
||||
$ingredientList
|
||||
.on("cocoon:after-insert", function(e, item) {
|
||||
reorder($ingredientList);
|
||||
initializeIngredientEditor(item, ingredientSearchEngine);
|
||||
})
|
||||
.on("cocoon:after-remove", function(e, item) {
|
||||
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);
|
||||
|
||||
$bulkIngredientsModal
|
||||
.on('show.bs.modal', function (event) {
|
||||
$ingredientBulkInput.val('');
|
||||
$ingredientBulkList.empty();
|
||||
autosize.update($ingredientBulkInput);
|
||||
});
|
||||
|
||||
$ingredientBulkInput.on('keyup', 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+)?(\w[\w ,\-\(\)\/'"]*)$/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 match = line.match(regex);
|
||||
|
||||
if (match) {
|
||||
parsed.push({quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(match[3])});
|
||||
} 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))
|
||||
);
|
||||
} else {
|
||||
$ingredientBulkList.append(
|
||||
$("<tr />")
|
||||
.append($("<td />").attr("colspan", "3").text("<Cannot Parse>"))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$("#bulkIngredientAddSubmit").on("click", function() {
|
||||
var parsed = $bulkIngredientsModal.data("bulkData");
|
||||
var x;
|
||||
|
||||
if (parsed && parsed.length) {
|
||||
for (x = 0; x < parsed.length; x++) {
|
||||
var item = parsed[x];
|
||||
if (item) {
|
||||
addIngredient(item.quantity, item.units, item.name, item.ingredient_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bulkIngredientsModal.modal('hide')
|
||||
});
|
||||
|
||||
|
||||
// ===========================================
|
||||
// ===========================================
|
||||
|
||||
|
||||
var $bulkStepsModal = $("#bulk_steps_modal");
|
||||
var $stepBulkInput = $("#step_bulk_input");
|
||||
var $stepBulkList = $("#step_bulk_parsed_list");
|
||||
autosize($stepBulkInput);
|
||||
|
||||
$bulkStepsModal
|
||||
.on('show.bs.modal', function (event) {
|
||||
$stepBulkInput.val('');
|
||||
$stepBulkList.empty();
|
||||
autosize.update($stepBulkInput);
|
||||
});
|
||||
|
||||
$stepBulkInput.on('keyup', function() {
|
||||
var data = $stepBulkInput.val();
|
||||
$stepBulkList.empty();
|
||||
|
||||
var parsed = [];
|
||||
var x;
|
||||
|
||||
var lines = data.replace("\r", "").split("\n\n");
|
||||
|
||||
for (x = 0; x < lines.length; x++) {
|
||||
var line = lines[x].trim().replace(/^\d+\./, "").trim();
|
||||
if (line.length == 0) { continue; }
|
||||
|
||||
parsed.push(line);
|
||||
}
|
||||
|
||||
$bulkStepsModal.data("bulkData", parsed);
|
||||
|
||||
for (x = 0; x < parsed.length; x++) {
|
||||
var item = parsed[x];
|
||||
if (item != null) {
|
||||
$stepBulkList.append(
|
||||
$("<tr />")
|
||||
.append($("<td />").addClass("step").text(x + 1))
|
||||
.append($("<td />").addClass("direction").text(item))
|
||||
);
|
||||
} else {
|
||||
$stepBulkList.append(
|
||||
$("<tr />")
|
||||
.append($("<td />").attr("colspan", "2").text("<Cannot Parse>"))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$("#bulkStepAddSubmit").on("click", function() {
|
||||
var parsed = $bulkStepsModal.data("bulkData");
|
||||
var x;
|
||||
|
||||
if (parsed && parsed.length) {
|
||||
for (x = 0; x < parsed.length; x++) {
|
||||
var item = parsed[x];
|
||||
if (item) {
|
||||
addStep(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bulkStepsModal.modal('hide')
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
})(jQuery);
|
@ -10,41 +10,4 @@
|
||||
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new
|
||||
* file per style scope.
|
||||
*
|
||||
*= require flash_messages
|
||||
*= require chosen
|
||||
*= require font_references
|
||||
*/
|
||||
|
||||
@import "bootstrap-sprockets";
|
||||
@import "readable/_variables";
|
||||
@import "bootstrap";
|
||||
@import "readable/_bootswatch";
|
||||
|
||||
@import "typeahead-bootstrap";
|
||||
@import "recipes";
|
||||
|
||||
$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;
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
|
||||
#flashHolder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#flashContainer {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.alert.popup {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
width: 30em;
|
||||
margin-left: -15em;
|
||||
}
|
||||
|
||||
.alert {
|
||||
}
|
||||
|
||||
.flash {
|
||||
text-align: center;
|
||||
width: 30em;
|
||||
padding: 5px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #fff6ff;
|
||||
border: 3px solid #fda8a8;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: #ffffdd;
|
||||
border: 3px solid #ffdd00;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background-color: #D8F6CE;
|
||||
border: 3px solid #3ADF00;
|
||||
}
|
@ -1,89 +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');
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
// Place all the styles related to the ingredients controller here.
|
||||
// They will automatically be included in application.css.
|
||||
// You can use Sass (SCSS) here: http://sass-lang.com/
|
@ -1,67 +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/
|
||||
|
||||
@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.step-editor {
|
||||
@include editor;
|
||||
|
||||
padding-bottom: 4px;
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sort-order-display {
|
||||
font-size: 120%;
|
||||
font-weight: normal;
|
||||
line-height: 50px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
div#ingredient-list, div#step-list {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
div.recipe-view {
|
||||
|
||||
.source {
|
||||
@extend .col-xs-6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ingredients div {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.steps div {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
18
app/channels/application_cable/connection.rb
Normal file
18
app/channels/application_cable/connection.rb
Normal file
@ -0,0 +1,18 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
self.current_user = find_verified_user
|
||||
end
|
||||
|
||||
private
|
||||
def find_verified_user
|
||||
if verified_user = User.find_by(id: cookies.encrypted['_parsley_session']['user_id'])
|
||||
verified_user
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
app/channels/task_channel.rb
Normal file
12
app/channels/task_channel.rb
Normal file
@ -0,0 +1,12 @@
|
||||
class TaskChannel < ApplicationCable::Channel
|
||||
|
||||
def subscribed
|
||||
stream_for current_user.id
|
||||
end
|
||||
|
||||
def self.update_task_list(task_list)
|
||||
task_list.reload
|
||||
self.broadcast_to task_list.user_id, { task_list: TaskListSerializer.for(task_list), action: 'updated' }
|
||||
end
|
||||
|
||||
end
|
44
app/controllers/admin/users_controller.rb
Normal file
44
app/controllers/admin/users_controller.rb
Normal file
@ -0,0 +1,44 @@
|
||||
module Admin
|
||||
class UsersController < ApplicationController
|
||||
before_action :set_user, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
before_action :ensure_admin_user
|
||||
|
||||
def index
|
||||
@users = User.order(:full_name)
|
||||
render json: UserSerializer.for(@users)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@user.assign_attributes(user_params)
|
||||
|
||||
if @user.save
|
||||
redirect_to admin_users_path, notice: 'User was successfully updated.'
|
||||
else
|
||||
render :edit
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@user.destroy
|
||||
redirect_to admin_users_path, notice: 'User was destroyed'
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_user
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def user_params
|
||||
params.require(:user).permit(:username, :email, :full_name, :admin, :password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
end
|
@ -3,6 +3,14 @@ class ApplicationController < ActionController::Base
|
||||
# For APIs, you may want to use :null_session instead.
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
def verified_request?
|
||||
if request.media_type == "application/json"
|
||||
true
|
||||
else
|
||||
super()
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_valid_user
|
||||
unless current_user?
|
||||
flash[:warning] = "You must login"
|
||||
@ -18,11 +26,35 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def ensure_owner(item)
|
||||
unless current_user && item && (item.user_id == current_user.id || current_user.admin?)
|
||||
flash[:warning] = "Operation Not Permitted"
|
||||
return redirect_to root_path
|
||||
owner = case
|
||||
when current_user.nil?
|
||||
false
|
||||
when item.nil?
|
||||
true
|
||||
when current_user.admin?
|
||||
true
|
||||
when current_user.id == item.user_id
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
|
||||
if owner
|
||||
yield if block_given?
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:warning] = "Operation Not Permitted"
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: { error: "Operation Not Permitted" }, status: current_user.nil? ? :unauthorized : :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
yield if block_given?
|
||||
end
|
||||
|
||||
def current_user
|
||||
|
68
app/controllers/calculator_controller.rb
Normal file
68
app/controllers/calculator_controller.rb
Normal file
@ -0,0 +1,68 @@
|
||||
class CalculatorController < ApplicationController
|
||||
|
||||
def index
|
||||
|
||||
end
|
||||
|
||||
def calculate
|
||||
input = params[:input]
|
||||
output_unit = params[:output_unit]
|
||||
ingredient_id = params[:ingredient_id]
|
||||
ingredient = nil
|
||||
density = params[:density]
|
||||
density = nil unless density.present?
|
||||
|
||||
if ingredient_id.present?
|
||||
ingredient = Ingredient.find_by_ingredient_id(ingredient_id)
|
||||
end
|
||||
|
||||
data = {errors: Hash.new { |h, k| h[k] = [] }, output: ''}
|
||||
|
||||
UnitConversion::with_custom_units(ingredient ? ingredient.custom_units : []) do
|
||||
density_unit = nil
|
||||
begin
|
||||
if density
|
||||
density_unit = UnitConversion.parse(density)
|
||||
unless density_unit.density?
|
||||
data[:errors][:density] << 'not a density unit'
|
||||
density_unit = nil
|
||||
end
|
||||
end
|
||||
rescue UnitConversion::UnparseableUnitError => e
|
||||
data[:errors][:density] << 'invalid string'
|
||||
end
|
||||
|
||||
begin
|
||||
input_unit = UnitConversion.parse(input)
|
||||
input_unit.unitwise
|
||||
rescue UnitConversion::UnparseableUnitError => e
|
||||
data[:errors][:input] << e.message
|
||||
input_unit = nil
|
||||
end
|
||||
|
||||
if !input_unit.nil?
|
||||
if output_unit.present?
|
||||
begin
|
||||
input_unit = input_unit.convert(output_unit, density_unit)
|
||||
rescue UnitConversion::UnparseableUnitError => e
|
||||
data[:errors][:output_unit] << e.message
|
||||
end
|
||||
else
|
||||
input_unit = input_unit.auto_unit
|
||||
end
|
||||
end
|
||||
|
||||
if data[:errors].empty?
|
||||
data[:output] = input_unit.to_s
|
||||
end
|
||||
end
|
||||
|
||||
render json: data
|
||||
end
|
||||
|
||||
def ingredient_search
|
||||
@foods = Food.has_density.search(params[:query]).order(:name)
|
||||
render json: @foods.map { |f| {id: f.id, name: f.name, density: f.density} }
|
||||
end
|
||||
|
||||
end
|
109
app/controllers/foods_controller.rb
Normal file
109
app/controllers/foods_controller.rb
Normal file
@ -0,0 +1,109 @@
|
||||
class FoodsController < ApplicationController
|
||||
|
||||
before_action :set_food, only: [:show, :update, :destroy]
|
||||
|
||||
before_action :ensure_valid_user, except: [:index, :show]
|
||||
|
||||
# GET /foods
|
||||
# GET /foods.json
|
||||
def index
|
||||
@foods = Food.all.order(:name).page(params[:page]).per(params[:per])
|
||||
if params[:name].present?
|
||||
@foods = @foods.matches_tokens(:name, params[:name].split.take(4))
|
||||
end
|
||||
render json: FoodSummarySerializer.for(@foods, collection_name: 'foods')
|
||||
end
|
||||
|
||||
def show
|
||||
render json: FoodSerializer.for(@food)
|
||||
end
|
||||
|
||||
# POST /foods
|
||||
# POST /foods.json
|
||||
def create
|
||||
@food = Food.new(food_params)
|
||||
@food.user = current_user
|
||||
if @food.ndbn.present?
|
||||
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
if @food.save
|
||||
format.html { redirect_to foods_path, notice: 'Ingredient was successfully created.' }
|
||||
format.json { render json: { success: true }, status: :created, location: @food }
|
||||
else
|
||||
format.html { render :new }
|
||||
format.json { render json: @food.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /foods/1
|
||||
def update
|
||||
ensure_owner @food do
|
||||
@food.assign_attributes(food_params)
|
||||
if @food.ndbn.present?
|
||||
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
if @food.save
|
||||
format.html { redirect_to foods_path, notice: 'Ingredient was successfully updated.' }
|
||||
format.json { render json: { success: true }, status: :ok, location: @food }
|
||||
else
|
||||
format.html { render :edit }
|
||||
format.json { render json: @food.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /foods/1
|
||||
# DELETE /foods/1.json
|
||||
def destroy
|
||||
ensure_owner @food do
|
||||
@food.destroy
|
||||
respond_to do |format|
|
||||
format.html { redirect_to foods_url, notice: 'Ingredient was successfully destroyed.' }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def select_ndbn
|
||||
if params[:id].present?
|
||||
@food = Food.find(params[:id])
|
||||
else
|
||||
@food = Food.new
|
||||
end
|
||||
|
||||
@food.assign_attributes(food_params)
|
||||
|
||||
if @food.ndbn.present?
|
||||
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
|
||||
end
|
||||
|
||||
render json: FoodSerializer.for(@food)
|
||||
end
|
||||
|
||||
def usda_food_search
|
||||
@foods = UsdaFood.search(params[:query]).limit(250).order(:long_description)
|
||||
|
||||
render json: UsdaFoodSerializer.for(@foods)
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_food
|
||||
@food = Food.find(params[:id])
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def food_params
|
||||
params.require(:food).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, :food_units_attributes => [:name, :gram_weight, :id, :_destroy])
|
||||
end
|
||||
|
||||
def conversion_params
|
||||
params.require(:conversion).permit(:input_quantity, :input_units, :scale, :output_units, :ingredient_id)
|
||||
end
|
||||
end
|
12
app/controllers/home_controller.rb
Normal file
12
app/controllers/home_controller.rb
Normal file
@ -0,0 +1,12 @@
|
||||
class HomeController < ApplicationController
|
||||
|
||||
skip_forgery_protection
|
||||
|
||||
def index
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def about
|
||||
|
||||
end
|
||||
end
|
@ -1,97 +1,19 @@
|
||||
class IngredientsController < ApplicationController
|
||||
|
||||
before_action :set_ingredient, only: [:edit, :update, :destroy]
|
||||
|
||||
before_filter :ensure_valid_user, only: [:new, :edit, :create, :update, :destroy]
|
||||
|
||||
# GET /ingredients
|
||||
# GET /ingredients.json
|
||||
def index
|
||||
@ingredients = Ingredient.all.order(:name)
|
||||
end
|
||||
|
||||
# GET /ingredients/new
|
||||
def new
|
||||
@ingredient = Ingredient.new
|
||||
end
|
||||
|
||||
# GET /ingredients/1/edit
|
||||
def edit
|
||||
end
|
||||
|
||||
# POST /ingredients
|
||||
# POST /ingredients.json
|
||||
def create
|
||||
@ingredient = Ingredient.new(ingredient_params)
|
||||
|
||||
respond_to do |format|
|
||||
if @ingredient.save
|
||||
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
|
||||
format.json { render :show, status: :created, location: @ingredient }
|
||||
else
|
||||
format.html { render :new }
|
||||
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /ingredients/1
|
||||
# PATCH/PUT /ingredients/1.json
|
||||
def update
|
||||
respond_to do |format|
|
||||
if @ingredient.update(ingredient_params)
|
||||
format.html { redirect_to @ingredient, notice: 'Ingredient was successfully updated.' }
|
||||
format.json { render :show, status: :ok, location: @ingredient }
|
||||
else
|
||||
format.html { render :edit }
|
||||
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /ingredients/1
|
||||
# DELETE /ingredients/1.json
|
||||
def destroy
|
||||
@ingredient.destroy
|
||||
respond_to do |format|
|
||||
format.html { redirect_to ingredients_url, notice: 'Ingredient was successfully destroyed.' }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def prefetch
|
||||
@ingredients = Ingredient.all.order(:name)
|
||||
render :search
|
||||
end
|
||||
|
||||
def search
|
||||
query = params[:query] + '%'
|
||||
@ingredients = Ingredient.where("name LIKE ?", query).order(:name)
|
||||
@ingredients = Food.search(params[:query]).order(:name).to_a
|
||||
@ingredients.concat(Recipe.is_ingredient.search_by_name(params[:query]).order(:name).to_a)
|
||||
@ingredients.sort { |a, b| a.name <=> b.name }
|
||||
|
||||
json = @ingredients.map do |i|
|
||||
{
|
||||
id: i.ingredient_id,
|
||||
ingredient_id: i.ingredient_id,
|
||||
name: i.name,
|
||||
density: i.density
|
||||
}
|
||||
end
|
||||
|
||||
render json: json
|
||||
end
|
||||
|
||||
def convert
|
||||
@conversion = Conversion.new(conversion_params)
|
||||
|
||||
if @conversion.valid?
|
||||
@output_quantity = @conversion.output_quantity
|
||||
@conversion = Conversion.new
|
||||
else
|
||||
@output_quantity = ''
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_ingredient
|
||||
@ingredient = Ingredient.find(params[:id])
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def ingredient_params
|
||||
params.require(:ingredient).permit(:name, :density, :notes)
|
||||
end
|
||||
|
||||
def conversion_params
|
||||
params.require(:conversion).permit(:input_quantity, :input_units, :scale, :output_units, :ingredient_id)
|
||||
end
|
||||
end
|
||||
|
86
app/controllers/logs_controller.rb
Normal file
86
app/controllers/logs_controller.rb
Normal file
@ -0,0 +1,86 @@
|
||||
class LogsController < ApplicationController
|
||||
|
||||
before_action :ensure_valid_user
|
||||
|
||||
before_action :set_log, only: [:show, :update, :destroy]
|
||||
before_action :set_recipe, only: [:new, :create]
|
||||
before_action :require_recipe, only: [:new, :create]
|
||||
|
||||
def index
|
||||
@logs = Log.for_user(current_user).order(date: :desc).page(params[:page]).per(params[:per])
|
||||
render json: LogSummarySerializer.for(@logs, collection_name: 'logs')
|
||||
end
|
||||
|
||||
def show
|
||||
ensure_owner(@log)
|
||||
render json: LogSerializer.for(@log)
|
||||
end
|
||||
|
||||
def edit
|
||||
ensure_owner(@log)
|
||||
render json: LogSerializer.for(@log)
|
||||
end
|
||||
|
||||
def update
|
||||
ensure_owner(@log) do
|
||||
if @log.update(log_params)
|
||||
render json: { success: true }
|
||||
else
|
||||
render json: @log.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@log = Log.new
|
||||
@log.date = Time.now
|
||||
@log.user = current_user
|
||||
@log.source_recipe = @recipe
|
||||
@log.recipe = @recipe.log_copy(current_user)
|
||||
end
|
||||
|
||||
def create
|
||||
@log = Log.new
|
||||
@log.assign_attributes(log_params)
|
||||
@log.recipe.is_log = true
|
||||
@log.recipe.user = current_user
|
||||
@log.user = current_user
|
||||
@log.source_recipe = @recipe
|
||||
|
||||
if @log.save
|
||||
render json: { success: true }
|
||||
else
|
||||
render json: @log.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
ensure_owner(@log) do
|
||||
@log.destroy
|
||||
redirect_to logs_url, notice: 'Log Entry was successfully destroyed.'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_log
|
||||
@log = Log.includes({recipe: {recipe_ingredients: {food: :food_units} }}).find(params[:id])
|
||||
end
|
||||
|
||||
def set_recipe
|
||||
if params[:recipe_id].present?
|
||||
@recipe = Recipe.includes([{recipe_ingredients: [:food]}]).find(params[:recipe_id])
|
||||
end
|
||||
end
|
||||
|
||||
def require_recipe
|
||||
unless @recipe
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
def log_params
|
||||
params.require(:log).permit(:date, :rating, :notes, recipe_attributes: [:name, :description, :source, :yields, :total_time, :active_time, :step_text, recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy]])
|
||||
end
|
||||
|
||||
end
|
74
app/controllers/notes_controller.rb
Normal file
74
app/controllers/notes_controller.rb
Normal file
@ -0,0 +1,74 @@
|
||||
class NotesController < ApplicationController
|
||||
before_action :set_note, only: [:show, :update, :destroy]
|
||||
before_action :ensure_valid_user
|
||||
|
||||
# GET /notes
|
||||
# GET /notes.json
|
||||
def index
|
||||
@notes = Note.for_user(current_user)
|
||||
render json: NoteSerializer.for(@notes)
|
||||
end
|
||||
|
||||
# GET /notes/1
|
||||
# GET /notes/1.json
|
||||
def show
|
||||
ensure_owner(@note)
|
||||
render json: NoteSerializer.for(@note)
|
||||
end
|
||||
|
||||
# POST /notes
|
||||
# POST /notes.json
|
||||
def create
|
||||
@note = Note.new(note_params)
|
||||
@note.user = current_user
|
||||
|
||||
respond_to do |format|
|
||||
if @note.save
|
||||
format.html { redirect_to notes_path, notice: 'Note was successfully created.' }
|
||||
format.json { render json: NoteSerializer.for(@note), status: :created, location: @note }
|
||||
else
|
||||
format.html { render :new }
|
||||
format.json { render json: @note.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /notes/1
|
||||
# PATCH/PUT /notes/1.json
|
||||
def update
|
||||
ensure_owner(@note) do
|
||||
respond_to do |format|
|
||||
if @note.update(note_params)
|
||||
format.html { redirect_to notes_path, notice: 'Note was successfully updated.' }
|
||||
format.json { render json: NoteSerializer.for(@note), status: :ok, location: @note }
|
||||
else
|
||||
format.html { render :edit }
|
||||
format.json { render json: @note.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /notes/1
|
||||
# DELETE /notes/1.json
|
||||
def destroy
|
||||
ensure_owner(@note) do
|
||||
@note.destroy
|
||||
respond_to do |format|
|
||||
format.html { redirect_to notes_url, notice: 'Note was successfully destroyed.' }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_note
|
||||
@note = Note.find(params[:id])
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def note_params
|
||||
params.require(:note).permit(:content)
|
||||
end
|
||||
end
|
@ -1,34 +1,45 @@
|
||||
class RecipesController < ApplicationController
|
||||
|
||||
before_action :set_recipe, only: [:show, :edit, :update, :destroy, :scale]
|
||||
before_action :set_recipe, only: [:show, :update, :destroy]
|
||||
|
||||
before_filter :ensure_valid_user, only: [:new, :edit, :create, :update, :destroy]
|
||||
before_action :ensure_valid_user, except: [:show, :index]
|
||||
|
||||
# GET /recipes
|
||||
def index
|
||||
@recipes = Recipe.active
|
||||
@criteria = ViewModels::RecipeCriteria.new(criteria_params)
|
||||
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
|
||||
render json: RecipeSummarySerializer.for(@recipes, collection_name: 'recipes')
|
||||
end
|
||||
|
||||
# GET /recipes/1
|
||||
# GET /recipes/1.json
|
||||
def show
|
||||
end
|
||||
if params[:scale].present?
|
||||
@scale = params[:scale]
|
||||
@recipe.scale(params[:scale], true)
|
||||
end
|
||||
|
||||
# GET /recipes/1
|
||||
def scale
|
||||
@scale = params[:factor]
|
||||
@recipe.scale(@scale, true)
|
||||
render :show
|
||||
end
|
||||
if params[:system].present?
|
||||
@system = params[:system]
|
||||
case @system
|
||||
when 'metric'
|
||||
@recipe.convert_to_metric
|
||||
when 'standard'
|
||||
@recipe.convert_to_standard
|
||||
end
|
||||
end
|
||||
|
||||
# GET /recipes/new
|
||||
def new
|
||||
@recipe = Recipe.new
|
||||
end
|
||||
if params[:unit].present?
|
||||
@unit = params[:unit]
|
||||
case @unit
|
||||
when 'mass'
|
||||
@recipe.convert_to_mass
|
||||
when 'volume'
|
||||
@recipe.convert_to_volume
|
||||
end
|
||||
end
|
||||
|
||||
# GET /recipes/1/edit
|
||||
def edit
|
||||
ensure_owner @recipe
|
||||
render json: RecipeSerializer.for(@recipe)
|
||||
end
|
||||
|
||||
# POST /recipes
|
||||
@ -37,44 +48,51 @@ class RecipesController < ApplicationController
|
||||
@recipe.user = current_user
|
||||
|
||||
if @recipe.save
|
||||
redirect_to @recipe, notice: 'Recipe was successfully created.'
|
||||
render json: { success: true }
|
||||
else
|
||||
render :new
|
||||
render json: @recipe.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /recipes/1
|
||||
def update
|
||||
ensure_owner(@recipe) do
|
||||
if @recipe.update(recipe_params)
|
||||
redirect_to @recipe, notice: 'Recipe was successfully updated.'
|
||||
# Merge in updated_at to force the record to be dirty (in case only tags were changed)
|
||||
if @recipe.update(recipe_params.merge(updated_at: Time.now))
|
||||
render json: { success: true }
|
||||
else
|
||||
render :edit
|
||||
render json: @recipe.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# POST /recipes/preview_steps
|
||||
def preview_steps
|
||||
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
|
||||
end
|
||||
|
||||
# DELETE /recipes/1
|
||||
def destroy
|
||||
ensure_owner(@recipe) do
|
||||
@recipe.deleted = true
|
||||
@recipe.save!(validate: false)
|
||||
|
||||
if @recipe.save
|
||||
redirect_to recipes_url, notice: 'Recipe was successfully destroyed.'
|
||||
else
|
||||
redirect_to recipes_url, error: 'Recipe could not be destroyed.'
|
||||
end
|
||||
render json: { success: true }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_recipe
|
||||
@recipe = Recipe.find(params[:id])
|
||||
@recipe = Recipe.includes(recipe_ingredients: [{food: :food_units }, {recipe_as_ingredient: {recipe_ingredients: {food: :food_units }}}]).find(params[:id])
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def recipe_params
|
||||
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, recipe_ingredients_attributes: [:custom_name, :custom_density, :ingredient_id, :quantity, :units, :sort_order, :id, :_destroy], recipe_steps_attributes: [:step, :sort_order, :id, :_destroy])
|
||||
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, :is_ingredient, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
|
||||
end
|
||||
|
||||
def criteria_params
|
||||
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
|
||||
end
|
||||
end
|
||||
|
17
app/controllers/tags_controller.rb
Normal file
17
app/controllers/tags_controller.rb
Normal file
@ -0,0 +1,17 @@
|
||||
class TagsController < ApplicationController
|
||||
|
||||
def index
|
||||
|
||||
end
|
||||
|
||||
def prefetch
|
||||
@tags = Tag.all.order(:name)
|
||||
render json: TagSerializer.for(@tags)
|
||||
end
|
||||
|
||||
def search
|
||||
@tags = Tag.search(params[:query]).order(:name)
|
||||
render json: TagSerializer.for(@tags)
|
||||
end
|
||||
|
||||
end
|
68
app/controllers/task_items_controller.rb
Normal file
68
app/controllers/task_items_controller.rb
Normal file
@ -0,0 +1,68 @@
|
||||
class TaskItemsController < ApplicationController
|
||||
|
||||
before_action :ensure_valid_user
|
||||
before_action :set_task_list
|
||||
before_action :set_task_item, only: [:update]
|
||||
|
||||
def create
|
||||
@task_item = TaskItem.new(task_item_params)
|
||||
@task_item.task_list = @task_list
|
||||
|
||||
if @task_item.save
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
render json: TaskItemSerializer.for(@task_item), status: :created, location: [@task_list, @task_item]
|
||||
else
|
||||
render json: @task_item.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @task_item.update(task_item_params)
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
render json: TaskItemSerializer.for(@task_item), status: :ok, location: [@task_list, @task_item]
|
||||
else
|
||||
render json: @task_item.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
|
||||
TaskItem.transaction do
|
||||
@task_items = @task_list.task_items.find(ids)
|
||||
@task_items.each { |i| i.destroy }
|
||||
end
|
||||
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def complete
|
||||
ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
|
||||
new_status = !params[:invert].present?
|
||||
TaskItem.transaction do
|
||||
@task_items = @task_list.task_items.find(ids)
|
||||
@task_items.each { |i| i.update_attribute(:completed, new_status) }
|
||||
end
|
||||
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def task_item_params
|
||||
params.require(:task_item).permit(:name, :quantity, :completed)
|
||||
end
|
||||
|
||||
def set_task_list
|
||||
@task_list = TaskList.find(params[:task_list_id])
|
||||
ensure_owner(@task_list)
|
||||
end
|
||||
|
||||
def set_task_item
|
||||
@task_item = @task_list.task_items.find(params[:id])
|
||||
end
|
||||
|
||||
end
|
66
app/controllers/task_lists_controller.rb
Normal file
66
app/controllers/task_lists_controller.rb
Normal file
@ -0,0 +1,66 @@
|
||||
class TaskListsController < ApplicationController
|
||||
|
||||
before_action :ensure_valid_user
|
||||
before_action :set_task_list, only: [:show, :update, :destroy, :add_recipe]
|
||||
|
||||
def index
|
||||
@task_lists = TaskList.for_user(current_user).includes(:task_items).order(created_at: :desc)
|
||||
render json: TaskListSerializer.for(@task_lists)
|
||||
end
|
||||
|
||||
def show
|
||||
ensure_owner(@task_list)
|
||||
render json: TaskListSerializer.for(@task_list)
|
||||
end
|
||||
|
||||
def create
|
||||
@task_list = TaskList.new(task_list_params)
|
||||
@task_list.user = current_user
|
||||
|
||||
if @task_list.save
|
||||
render json: TaskListSerializer.for(@task_list), status: :created, location: @task_list
|
||||
else
|
||||
render json: @task_list.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
ensure_owner(@task_list) do
|
||||
if @task_list.update(task_list_params)
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
render json: TaskListSerializer.for(@task_list), status: :ok, location: @task_list
|
||||
else
|
||||
render json: @task_list.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
ensure_owner(@task_list) do
|
||||
@task_list.destroy
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
|
||||
def add_recipe
|
||||
ensure_owner(@task_list) do
|
||||
recipe = Recipe.find(params[:recipe_id])
|
||||
|
||||
@task_list.add_recipe_ingredients(recipe)
|
||||
TaskChannel.update_task_list(@task_list)
|
||||
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def task_list_params
|
||||
params.require(:task_list).permit(:name)
|
||||
end
|
||||
|
||||
def set_task_list
|
||||
@task_list = TaskList.find(params[:id])
|
||||
end
|
||||
|
||||
end
|
@ -1,6 +1,15 @@
|
||||
class UsersController < ApplicationController
|
||||
|
||||
before_filter :ensure_valid_user, except: [:login, :verify_login, :new, :create]
|
||||
before_action :ensure_valid_user, except: [:show, :login, :verify_login, :new, :create]
|
||||
skip_before_action :verify_authenticity_token, only: [:verify_login]
|
||||
|
||||
def show
|
||||
if current_user
|
||||
render json: UserSerializer.for(current_user)
|
||||
else
|
||||
render json: nil
|
||||
end
|
||||
end
|
||||
|
||||
def login
|
||||
|
||||
@ -9,18 +18,24 @@ class UsersController < ApplicationController
|
||||
def logout
|
||||
set_current_user(nil)
|
||||
session.destroy
|
||||
flash[:notice] = "Logged out"
|
||||
redirect_to root_path
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to root_path, notice: "Logged out" }
|
||||
format.json { render json: { success: true } }
|
||||
end
|
||||
end
|
||||
|
||||
def verify_login
|
||||
if user = User.authenticate(params[:username], params[:password])
|
||||
set_current_user(user)
|
||||
flash[:notice] = "Welcome, #{user.display_name}"
|
||||
redirect_to root_path
|
||||
else
|
||||
flash[:error] = "Invalid credentials"
|
||||
render :login
|
||||
|
||||
respond_to do |format|
|
||||
if user = User.authenticate(params[:username], params[:password])
|
||||
set_current_user(user)
|
||||
format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
|
||||
format.json { render json: { success: true, user: UserSerializer.for(current_user).serialize } }
|
||||
else
|
||||
format.html { flash[:error] = "Invalid credentials"; render :login }
|
||||
format.json { render json: { success: false, message: 'Invalid Credentials', user: nil } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -31,11 +46,15 @@ class UsersController < ApplicationController
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
if @user.save
|
||||
set_current_user(@user)
|
||||
redirect_to root_path, notice: 'User was successfully created.'
|
||||
else
|
||||
render action: :new
|
||||
respond_to do |format|
|
||||
if @user.save
|
||||
set_current_user(@user)
|
||||
format.html { redirect_to root_path, notice: 'User created.' }
|
||||
format.json { render json: UserSerializer.for(@user), status: :created, location: @user }
|
||||
else
|
||||
format.html { render :new }
|
||||
format.json { render json: @user.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -45,10 +64,15 @@ class UsersController < ApplicationController
|
||||
|
||||
def update
|
||||
@user = current_user
|
||||
if @user.update(user_params)
|
||||
redirect_to root_path, notice: 'User account updated'
|
||||
else
|
||||
render action: 'edit'
|
||||
|
||||
respond_to do |format|
|
||||
if @user.update(user_params)
|
||||
format.html { redirect_to root_path, notice: 'User updated.' }
|
||||
format.json { render json: UserSerializer.for(@user) , status: :created, location: @user }
|
||||
else
|
||||
format.html { render :edit }
|
||||
format.json { render json: @user.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
54
app/decorators/base_decorator.rb
Normal file
54
app/decorators/base_decorator.rb
Normal file
@ -0,0 +1,54 @@
|
||||
require 'delegate'
|
||||
|
||||
# Minimal Decorator Base Class
|
||||
#
|
||||
# Implements a SimpleDelegator, provides access to the view_context through the `h` method, and provides
|
||||
# a factory method to construct Delegators or collections of Delegators
|
||||
#
|
||||
# In a controller, the view_context can be retrieved by calling a method of the same name
|
||||
# In a view or view helper, the context is `self`
|
||||
#
|
||||
# See also ApplicationHelper#decorate
|
||||
class BaseDecorator < SimpleDelegator
|
||||
|
||||
class << self
|
||||
# Decorates a single object or a collection of objects. If the given objects are `BaseDecorators`, they will be
|
||||
# unwrapped first
|
||||
def decorate(obj, view_context)
|
||||
decorated = Array.wrap(obj).map do |o|
|
||||
case o
|
||||
when nil
|
||||
nil
|
||||
when BaseDecorator
|
||||
new(o.__getobj__, view_context)
|
||||
else
|
||||
new(o, view_context)
|
||||
end
|
||||
end
|
||||
obj.respond_to?(:each) ? decorated : decorated.first
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(base, view_context)
|
||||
super(base)
|
||||
@view_context = view_context
|
||||
end
|
||||
|
||||
def h
|
||||
@view_context
|
||||
end
|
||||
|
||||
# For some reason, view_context.html_escape is marked as private. To provide access to the same functionality,
|
||||
# define it here
|
||||
def html_escape(*args)
|
||||
ERB::Util.html_escape(*args)
|
||||
end
|
||||
|
||||
def markdown(text)
|
||||
MarkdownProcessor.render(text).html_safe
|
||||
end
|
||||
|
||||
def wrapped
|
||||
__getobj__
|
||||
end
|
||||
end
|
17
app/decorators/ingredient_decorator.rb
Normal file
17
app/decorators/ingredient_decorator.rb
Normal file
@ -0,0 +1,17 @@
|
||||
class IngredientDecorator < BaseDecorator
|
||||
|
||||
def ndbn_check
|
||||
if ndbn.present?
|
||||
"<span class =\"glyphicon glyphicon-ok\"></span>".html_safe
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def density
|
||||
if wrapped.density.present?
|
||||
value = UnitConversion::parse(wrapped.density)
|
||||
value.convert('oz/cup').change_formatter(UnitConversion::DecimalFormatter.new).pretty_value
|
||||
end
|
||||
end
|
||||
end
|
16
app/decorators/log_decorator.rb
Normal file
16
app/decorators/log_decorator.rb
Normal file
@ -0,0 +1,16 @@
|
||||
class LogDecorator < BaseDecorator
|
||||
|
||||
def recipe
|
||||
RecipeDecorator.decorate(wrapped.recipe, h)
|
||||
end
|
||||
|
||||
def date
|
||||
v = super
|
||||
if v && v.respond_to?(:strftime)
|
||||
v.strftime("%Y-%m-%d")
|
||||
else
|
||||
v
|
||||
end
|
||||
end
|
||||
|
||||
end
|
12
app/decorators/note_decorator.rb
Normal file
12
app/decorators/note_decorator.rb
Normal file
@ -0,0 +1,12 @@
|
||||
class NoteDecorator < BaseDecorator
|
||||
|
||||
def date
|
||||
v = super
|
||||
if v && v.respond_to?(:strftime)
|
||||
v.strftime("%Y-%m-%d")
|
||||
else
|
||||
v
|
||||
end
|
||||
end
|
||||
|
||||
end
|
19
app/decorators/nutrition_data_decorator.rb
Normal file
19
app/decorators/nutrition_data_decorator.rb
Normal file
@ -0,0 +1,19 @@
|
||||
class NutritionDataDecorator < BaseDecorator
|
||||
|
||||
def format_number(n)
|
||||
'%.1f' % n
|
||||
end
|
||||
|
||||
NutritionData::NUTRIENTS.keys.each do |m|
|
||||
|
||||
define_method m do
|
||||
format_number(super())
|
||||
end
|
||||
|
||||
define_method "#{m}_per" do |per|
|
||||
format_number(wrapped.send(m) / per)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
42
app/decorators/recipe_decorator.rb
Normal file
42
app/decorators/recipe_decorator.rb
Normal file
@ -0,0 +1,42 @@
|
||||
class RecipeDecorator < BaseDecorator
|
||||
|
||||
NAME_CUTOFF = 35
|
||||
|
||||
def short_name
|
||||
if wrapped.name.length >= NAME_CUTOFF
|
||||
wrapped.name[0...NAME_CUTOFF] + '...'
|
||||
else
|
||||
wrapped.name
|
||||
end
|
||||
end
|
||||
|
||||
def created_at
|
||||
wrapped.created_at ? wrapped.created_at.strftime('%D') : ''
|
||||
end
|
||||
|
||||
def tag_names
|
||||
tags = wrapped.tag_names
|
||||
tags.map do |t|
|
||||
h.content_tag('span', t, class: 'label label-default')
|
||||
end.join(' ').html_safe
|
||||
end
|
||||
|
||||
def source_markup
|
||||
uri = begin
|
||||
URI.parse(self.source)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
if uri.is_a? URI::HTTP
|
||||
h.link_to(uri.host, uri.to_s)
|
||||
else
|
||||
h.content_tag('span', self.source)
|
||||
end
|
||||
end
|
||||
|
||||
def step_text
|
||||
markdown(wrapped.step_text)
|
||||
end
|
||||
|
||||
end
|
@ -1,49 +1,16 @@
|
||||
module ApplicationHelper
|
||||
|
||||
def timestamp(time)
|
||||
time ? time.strftime('%D %R') : ''
|
||||
# Given a model or collection of models, returns them with the given decorator. If a block is passed, yields the
|
||||
# decorated objects as well
|
||||
#
|
||||
# Useful in this context:
|
||||
# <% decorate(@model_obj, ModelObjDecorator) do |model_obj| %>
|
||||
# <%= model_obj.decorator_method %>
|
||||
# <% end %>
|
||||
def decorate(obj, decorator_class)
|
||||
decorated = decorator_class.decorate(obj, self)
|
||||
yield(decorated) if block_given?
|
||||
decorated
|
||||
end
|
||||
|
||||
def nav_items
|
||||
[
|
||||
nav_item('Recipes', recipes_path, 'recipes'),
|
||||
nav_item('Ingredients', ingredients_path, 'ingredients')
|
||||
]
|
||||
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)
|
||||
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
|
||||
|
4
app/helpers/foods_helper.rb
Normal file
4
app/helpers/foods_helper.rb
Normal file
@ -0,0 +1,4 @@
|
||||
module FoodsHelper
|
||||
|
||||
|
||||
end
|
@ -1,2 +0,0 @@
|
||||
module IngredientsHelper
|
||||
end
|
@ -1,25 +1,3 @@
|
||||
module RecipesHelper
|
||||
def recipe_time(recipe)
|
||||
output = ''.html_safe
|
||||
|
||||
if recipe.total_time.present?
|
||||
output << "#{humanize_seconds(recipe.total_time.to_i.minutes)}"
|
||||
if recipe.active_time.present?
|
||||
output << " (#{humanize_seconds(recipe.active_time.to_i.minutes)} active)"
|
||||
end
|
||||
elsif recipe.active_time.present?
|
||||
output << humanize_seconds(recipe.active_time.to_i.minutes)
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
|
||||
def humanize_seconds(secs)
|
||||
[[60, :s], [60, :m], [24, :h], [1000, :d]].map{ |count, name|
|
||||
if secs > 0
|
||||
secs, n = secs.divmod(count)
|
||||
n == 0 ? nil : "#{n.to_i} #{name}"
|
||||
end
|
||||
}.compact.reverse.join(' ')
|
||||
end
|
||||
end
|
||||
|
67
app/javascript/components/App.vue
Normal file
67
app/javascript/components/App.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<app-progress-bar></app-progress-bar>
|
||||
<app-navbar></app-navbar>
|
||||
<section id="main" class="">
|
||||
<div class="container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component v-if="!hasError" :is="Component" />
|
||||
<div v-else>
|
||||
<h1>Error!</h1>
|
||||
<p>{{ appConfig.error }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
import { useCheckAuthentication } from "../lib/useCheckAuthentication";
|
||||
import AppProgressBar from "./AppProgressBar.vue";
|
||||
|
||||
const globalTweenGroup = useGlobalTweenGroup();
|
||||
let animationLoop = true;
|
||||
|
||||
const appConfig = useAppConfigStore();
|
||||
const hasError = computed(() => appConfig.error !== null);
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const { checkAuthentication } = useCheckAuthentication(loadResource);
|
||||
|
||||
watch(
|
||||
() => appConfig.initialLoad,
|
||||
(val) => {
|
||||
if (val) {
|
||||
nextTick(() => document.body.classList.remove("loading"));
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Setup global animation loop
|
||||
function animate() {
|
||||
if (animationLoop) {
|
||||
globalTweenGroup.update();
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
animate();
|
||||
|
||||
if (appConfig.user === null && appConfig.authChecked === false) {
|
||||
checkAuthentication();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
animationLoop = false;
|
||||
});
|
||||
|
||||
</script>
|
254
app/javascript/components/AppAutocomplete.vue
Normal file
254
app/javascript/components/AppAutocomplete.vue
Normal file
@ -0,0 +1,254 @@
|
||||
<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 setup>
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "inputClick", "optionSelected"]);
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: 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,
|
||||
keyAttribute: String,
|
||||
|
||||
onGetOptions: Function,
|
||||
searchOptions: Array
|
||||
});
|
||||
|
||||
const options = ref([]);
|
||||
const rawValue = ref("");
|
||||
const isListOpen = ref(false);
|
||||
const activeListIndex = ref(0);
|
||||
|
||||
const finalInputClass = computed(() => {
|
||||
let cls = ['input'];
|
||||
if (props.inputClass === null) {
|
||||
return cls;
|
||||
} else if (Array.isArray(props.inputClass)) {
|
||||
return cls.concat(props.inputClass);
|
||||
} else {
|
||||
cls.push(props.inputClass);
|
||||
return cls;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => { rawValue.value = newValue; },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function optionClass(idx) {
|
||||
return activeListIndex.value === idx ? 'option active' : 'option';
|
||||
}
|
||||
|
||||
function optionClick(opt) {
|
||||
selectOption(opt);
|
||||
}
|
||||
|
||||
function optionKey(opt) {
|
||||
if (props.keyAttribute) {
|
||||
return opt[props.keyAttribute]
|
||||
} else if (props.valueAttribute) {
|
||||
return opt[props.valueAttribute];
|
||||
} else {
|
||||
return opt.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function optionValue(opt) {
|
||||
if (props.valueAttribute) {
|
||||
return opt[props.valueAttribute];
|
||||
} else {
|
||||
return opt.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function optionLabel(opt) {
|
||||
if (props.labelAttribute) {
|
||||
return opt[props.labelAttribute];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function optionMousemove(idx) {
|
||||
activeListIndex.value = idx;
|
||||
}
|
||||
|
||||
function clickHandler(evt) {
|
||||
emit('inputClick', evt);
|
||||
}
|
||||
|
||||
function 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(() => {
|
||||
isListOpen.value = false;
|
||||
},250);
|
||||
}
|
||||
|
||||
function inputHandler(evt) {
|
||||
const newValue = evt.target.value;
|
||||
|
||||
if (rawValue.value !== newValue) {
|
||||
|
||||
rawValue.value = newValue;
|
||||
|
||||
emit("update:modelValue", newValue);
|
||||
|
||||
if (newValue.length >= Math.max(1, props.minLength)) {
|
||||
updateOptions(newValue);
|
||||
} else {
|
||||
isListOpen.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keydownHandler(evt) {
|
||||
if (isListOpen.value === false)
|
||||
return;
|
||||
|
||||
switch (evt.key) {
|
||||
case "ArrowUp":
|
||||
evt.preventDefault();
|
||||
activeListIndex.value = Math.max(0, activeListIndex.value - 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
evt.preventDefault();
|
||||
activeListIndex.value = Math.min(options.value.length - 1, activeListIndex.value + 1);
|
||||
break;
|
||||
case "Enter":
|
||||
evt.preventDefault();
|
||||
selectOption(options.value[activeListIndex.value]);
|
||||
break;
|
||||
case "Escape":
|
||||
evt.preventDefault();
|
||||
isListOpen.value = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(opt) {
|
||||
rawValue.value = optionValue(opt);
|
||||
emit("update:modelValue", rawValue.value);
|
||||
emit("optionSelected", opt);
|
||||
isListOpen.value = false;
|
||||
}
|
||||
|
||||
const updateOptions = debounce(function(value) {
|
||||
let p = null;
|
||||
if (props.searchOptions) {
|
||||
const reg = new RegExp("^" + value, "i");
|
||||
const matcher = o => reg.test(optionValue(o));
|
||||
p = Promise.resolve(props.searchOptions.filter(matcher));
|
||||
} else {
|
||||
p = props.onGetOptions(value)
|
||||
}
|
||||
|
||||
p.then(opts => {
|
||||
options.value = opts;
|
||||
isListOpen.value = opts.length > 0;
|
||||
activeListIndex.value = 0;
|
||||
})
|
||||
}, props.debounce);
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@use "bulma/sass/utilities" as bulma;
|
||||
|
||||
$labelLineHeight: 0.8rem;
|
||||
|
||||
input.input {
|
||||
&::placeholder {
|
||||
color: bulma.$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: bulma.$turquoise;
|
||||
}
|
||||
|
||||
.opt_value {
|
||||
}
|
||||
|
||||
.opt_label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.8rem;
|
||||
line-height: $labelLineHeight;
|
||||
max-height: $labelLineHeight * 2;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
45
app/javascript/components/AppConfirm.vue
Normal file
45
app/javascript/components/AppConfirm.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<app-modal :open="open" :title="title" @dismiss="runCancel">
|
||||
<p class="is-size-5">{{ message }}</p>
|
||||
|
||||
<template #footer>
|
||||
<div class="buttons">
|
||||
<button type="button" class="button is-primary" @click="runConfirm">OK</button>
|
||||
<button type="button" class="button" @click="runCancel">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</app-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const emit = defineEmits(["cancel", "confirm"]);
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'Are you sure?'
|
||||
},
|
||||
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "Confirm"
|
||||
},
|
||||
|
||||
open: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function runConfirm() {
|
||||
emit("confirm");
|
||||
}
|
||||
|
||||
function runCancel() {
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
</script>
|
38
app/javascript/components/AppDatePicker.vue
Normal file
38
app/javascript/components/AppDatePicker.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<app-text-field :value="stringValue" @update:modelValue="input" :label="label" type="date"></app-text-field>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
import DateTimeUtils from "../lib/DateTimeUtils";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: false,
|
||||
type: [Date, String]
|
||||
},
|
||||
|
||||
label: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const stringValue = computed(() => {
|
||||
const d = DateTimeUtils.toDate(props.modelValue);
|
||||
return DateTimeUtils.formatDateForEdit(d);
|
||||
});
|
||||
|
||||
function input(val) {
|
||||
let d = DateTimeUtils.toDate(val + " 00:00");
|
||||
emit("update:modelValue", d);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
55
app/javascript/components/AppDateTime.vue
Normal file
55
app/javascript/components/AppDateTime.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<span>
|
||||
<input v-if="useInput" class="text" type="text" readonly :value="friendlyString" />
|
||||
<span v-else :title="fullString">{{ friendlyString }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
import DateTimeUtils from "../lib/DateTimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
const dateObj = computed(() => DateTimeUtils.toDate(props.dateTime));
|
||||
const fullString = computed(() => DateTimeUtils.formatTimestamp(dateObj.value));
|
||||
|
||||
const friendlyString = computed(() => {
|
||||
const parts = [];
|
||||
if (props.showDate) {
|
||||
parts.push(DateTimeUtils.formatDate(dateObj.value));
|
||||
}
|
||||
if (props.showTime) {
|
||||
parts.push(DateTimeUtils.formatTime(dateObj.value, true));
|
||||
}
|
||||
return parts.join(" ");
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
81
app/javascript/components/AppDropdown.vue
Normal file
81
app/javascript/components/AppDropdown.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div ref="dropdown" class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}">
|
||||
<div class="dropdown-trigger">
|
||||
<slot name="button">
|
||||
<button type="button" class="button" :class="buttonClass" @click="toggle">
|
||||
<span>{{ label }}</span>
|
||||
<app-icon icon="caret-bottom" size="xs"></app-icon>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-content">
|
||||
<slot>
|
||||
Default Content
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { useTemplateRef } from "vue";
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const emit = defineEmits(["close", "open"]);
|
||||
const props = defineProps({
|
||||
open: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
hover: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
label: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: 'Select'
|
||||
},
|
||||
|
||||
buttonClass: {
|
||||
required: false,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
const dropdownElement = useTemplateRef("dropdown");
|
||||
|
||||
onClickOutside(dropdownElement, event => handleOutsideClick(event))
|
||||
|
||||
function toggle() {
|
||||
if (props.open) {
|
||||
triggerClose();
|
||||
} else {
|
||||
triggerOpen();
|
||||
}
|
||||
}
|
||||
|
||||
function triggerOpen() {
|
||||
emit("open");
|
||||
}
|
||||
|
||||
function triggerClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleOutsideClick(evt) {
|
||||
if (props.open) {
|
||||
if (!dropdownElement.value.contains(evt.target)) {
|
||||
triggerClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
104
app/javascript/components/AppExpandTransition.vue
Normal file
104
app/javascript/components/AppExpandTransition.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<transition
|
||||
name="expand"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
@enter-cancel="cancel"
|
||||
@leave-cancel="cancel">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import TWEEN from '@tweenjs/tween.js';
|
||||
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
|
||||
|
||||
const props = defineProps({
|
||||
expandTime: {
|
||||
type: Number,
|
||||
default: 250
|
||||
}
|
||||
});
|
||||
|
||||
let animation = null;
|
||||
|
||||
function cancel () {
|
||||
if (animation) {
|
||||
animation.stop();
|
||||
animation = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enter(element, done) {
|
||||
const width = parseInt(getComputedStyle(element).width);
|
||||
const paddingTop = parseInt(getComputedStyle(element).paddingTop);
|
||||
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
|
||||
|
||||
element.style.width = width;
|
||||
element.style.position = 'absolute';
|
||||
element.style.visibility = 'hidden';
|
||||
element.style.height = 'auto';
|
||||
|
||||
const height = parseInt(getComputedStyle(element).height);
|
||||
|
||||
element.style.width = null;
|
||||
element.style.position = null;
|
||||
element.style.visibility = null;
|
||||
element.style.overflow = 'hidden';
|
||||
element.style.height = 0;
|
||||
|
||||
animation = new TWEEN.Tween({height: 0, paddingTop: 0, paddingBottom: 0})
|
||||
.to({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}, props.expandTime)
|
||||
.onUpdate(obj => {
|
||||
element.style.height = obj.height + "px";
|
||||
element.style.paddingBottom = obj.paddingBottom + "px";
|
||||
element.style.paddingTop = obj.paddingTop + "px";
|
||||
})
|
||||
.onComplete(() => {
|
||||
animation = null;
|
||||
element.removeAttribute('style');
|
||||
element.style.opacity = 0.99;
|
||||
setTimeout(() => {
|
||||
// Fixes odd drawing bug in Chrome
|
||||
element.style.opacity = 1.0;
|
||||
}, 1000);
|
||||
done();
|
||||
})
|
||||
.group(useGlobalTweenGroup())
|
||||
.start();
|
||||
}
|
||||
|
||||
function leave(element, done) {
|
||||
const height = parseInt(getComputedStyle(element).height);
|
||||
const paddingTop = parseInt(getComputedStyle(element).paddingTop);
|
||||
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
|
||||
|
||||
element.style.overflow = 'hidden';
|
||||
|
||||
animation = new TWEEN.Tween({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom})
|
||||
.to({height: 0, paddingTop: 0, paddingBottom: 0}, props.expandTime)
|
||||
.onUpdate(obj => {
|
||||
element.style.height = obj.height + "px";
|
||||
element.style.paddingBottom = obj.paddingBottom + "px";
|
||||
element.style.paddingTop = obj.paddingTop + "px";
|
||||
})
|
||||
.onComplete(() => {
|
||||
animation = null;
|
||||
element.removeAttribute('style');
|
||||
done();
|
||||
})
|
||||
.group(useGlobalTweenGroup())
|
||||
.start();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
will-change: height;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
</style>
|
132
app/javascript/components/AppIcon.vue
Normal file
132
app/javascript/components/AppIcon.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<span class="icon" :class="iconClasses" @click="$emit('click', $event)">
|
||||
<app-iconic-icon :icon="iconicIcon" :size="iconicSize" v-bind="iconicAttributes"></app-iconic-icon>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
class IconData {
|
||||
constructor(iconicIcon, dataAttributes) {
|
||||
this.iconicIcon = iconicIcon;
|
||||
this.dataAttributes = dataAttributes || {};
|
||||
}
|
||||
}
|
||||
|
||||
class SizeData {
|
||||
constructor(bulmaIconClass, iconicSize, customClass) {
|
||||
this.bulmaIconClass = bulmaIconClass;
|
||||
this.iconicSize = iconicSize || null;
|
||||
this.customIconClass = customClass || null;
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
'caret-bottom': new IconData('caret', {'data-direction': 'bottom'}),
|
||||
'caret-top': new IconData('caret', {'data-direction': 'top'}),
|
||||
check: new IconData('check'),
|
||||
'circle-check': new IconData('circle-check'),
|
||||
'link-broken': new IconData('link', {'data-state': 'broken'}),
|
||||
'link-intact': new IconData('link', {'data-state': 'intact'}),
|
||||
'lock-locked': new IconData('lock', {'data-state': 'locked'}),
|
||||
'lock-unlocked': new IconData('lock', {'data-state': 'unlocked'}),
|
||||
menu: new IconData('menu'),
|
||||
pencil: new IconData('pencil'),
|
||||
person: new IconData('person'),
|
||||
'question-mark': new IconData('question-mark'),
|
||||
star: new IconData('star'),
|
||||
'star-empty': new IconData('star-empty'),
|
||||
warning: new IconData('warning'),
|
||||
x: new IconData('x')
|
||||
};
|
||||
|
||||
const sizeMap = {
|
||||
xs: new SizeData('is-small', 'sm', 'is-xs'),
|
||||
sm: new SizeData('is-small', 'sm'),
|
||||
md: new SizeData('', 'sm', 'is-md'),
|
||||
lg: new SizeData('is-medium', 'md'),
|
||||
xl: new SizeData('is-large', 'md', 'is-xl')
|
||||
};
|
||||
|
||||
export default {
|
||||
emits: ["click"],
|
||||
props: {
|
||||
icon: {
|
||||
validator: (i) => iconMap[i] !== undefined
|
||||
},
|
||||
size: {
|
||||
required: false,
|
||||
type: String,
|
||||
validator: (s) => sizeMap[s] !== undefined,
|
||||
default: 'md'
|
||||
},
|
||||
padding: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const iconData = computed(() => iconMap[props.icon]);
|
||||
const sizeData = computed(() => sizeMap[props.size]);
|
||||
const iconClasses = computed(() => [sizeData.value.bulmaIconClass, sizeData.value.customIconClass]);
|
||||
const iconicSize = computed(() => sizeData.value.iconicSize);
|
||||
const iconicIcon = computed(() => iconData.value.iconicIcon);
|
||||
const iconicAttributes = computed(() => iconData.value.dataAttributes);
|
||||
|
||||
return {
|
||||
iconClasses,
|
||||
iconData,
|
||||
sizeData,
|
||||
iconicAttributes,
|
||||
iconicIcon,
|
||||
iconicSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
span.icon.is-xs {
|
||||
svg.iconic {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
span.icon.is-sm {
|
||||
svg.iconic {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
span.icon.is-md {
|
||||
svg.iconic {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
span.icon.is-lg {
|
||||
svg.iconic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
span.icon.is-xl {
|
||||
svg.iconic {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
156
app/javascript/components/AppIconicIcon.vue
Normal file
156
app/javascript/components/AppIconicIcon.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<svg ref="svg" v-bind="svgAttributes" v-html="svgContent" :class="calculatedClasses"></svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { computed, nextTick, onMounted, onUpdated, useTemplateRef } from "vue";
|
||||
|
||||
import Caret from "../iconic/svg/smart/caret";
|
||||
import Check from "../iconic/svg/smart/check";
|
||||
import CircleCheck from "../iconic/svg/smart/circle-check";
|
||||
import Link from "../iconic/svg/smart/link";
|
||||
import Lock from "../iconic/svg/smart/lock";
|
||||
import Menu from "../iconic/svg/smart/menu";
|
||||
import QuestionMark from "../iconic/svg/smart/question-mark.svg"
|
||||
import Person from "../iconic/svg/smart/person";
|
||||
import Pencil from "../iconic/svg/smart/pencil";
|
||||
import Star from "../iconic/svg/smart/star";
|
||||
import StarEmpty from "../iconic/svg/smart/star-empty";
|
||||
import Warning from "../iconic/svg/smart/warning";
|
||||
import X from "../iconic/svg/smart/x";
|
||||
|
||||
const APIS = {};
|
||||
const LOADED_APIS = {};
|
||||
|
||||
window._Iconic = {
|
||||
smartIconApis: APIS
|
||||
};
|
||||
|
||||
let globalIdCounter = 0;
|
||||
|
||||
const iconMap = {
|
||||
caret: Caret,
|
||||
check: Check,
|
||||
'circle-check': CircleCheck,
|
||||
link: Link,
|
||||
lock: Lock,
|
||||
menu: Menu,
|
||||
pencil: Pencil,
|
||||
person: Person,
|
||||
'question-mark': QuestionMark,
|
||||
star: Star,
|
||||
'star-empty': StarEmpty,
|
||||
warning: Warning,
|
||||
x: X
|
||||
};
|
||||
|
||||
export default {
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (i) => iconMap[i] !== undefined
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: "lg",
|
||||
validator: (s) => ["sm", "md", "lg"].indexOf(s) >= 0
|
||||
},
|
||||
|
||||
iconSizeOverride: {
|
||||
type: String,
|
||||
validator: (s) => ["sm", "md", "lg", null].indexOf(s) >= 0
|
||||
},
|
||||
|
||||
displaySizeOverride: {
|
||||
type: String,
|
||||
validator: (s) => ["sm", "md", "lg", null].indexOf(s) >= 0
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const svgElement = useTemplateRef("svg");
|
||||
const svgData = computed(() => iconMap[props.icon]);
|
||||
const svgAttributes = computed(() => svgData.value.attributes);
|
||||
const svgName = computed(() => svgAttributes.value['data-icon']);
|
||||
const svgContent = computed(() => {
|
||||
let content = String(svgData.value.content);
|
||||
|
||||
for (let idRep of svgData.value.idReplacements) {
|
||||
let newId = `__new_id_${globalIdCounter}`;
|
||||
globalIdCounter += 1;
|
||||
|
||||
content = content.replace(new RegExp(idRep, "g"), newId);
|
||||
}
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
const calculatedClasses = computed(() => {
|
||||
const classes = (svgAttributes.value.class || "").split(" ");
|
||||
|
||||
classes.push(`iconic-${props.size}`);
|
||||
|
||||
if (props.iconSizeOverride) {
|
||||
classes.push(`iconic-icon-${props.iconSizeOverride}`);
|
||||
}
|
||||
|
||||
if (props.displaySizeOverride) {
|
||||
classes.push(`iconic-size-${props.displaySizeOverride}`);
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
|
||||
function ensureSvgApi(name, scripts) {
|
||||
if (!name) { return; }
|
||||
if (LOADED_APIS[name] !== true) {
|
||||
for (let sb of scripts) {
|
||||
try {
|
||||
new Function(sb)(window);
|
||||
} catch (e) {
|
||||
console.log(sb);
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
LOADED_APIS[name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setupSvgApi(name) {
|
||||
const apis = APIS;
|
||||
if (apis && apis[name]) {
|
||||
const iconApi = apis[name](svgElement.value);
|
||||
for (let func in iconApi) svgElement.value[func] = iconApi[func]
|
||||
} else {
|
||||
svgElement.value.update = function() {}
|
||||
}
|
||||
svgElement.value.update();
|
||||
}
|
||||
|
||||
function updateScripts() {
|
||||
ensureSvgApi(svgName.value, svgData.value.scriptBlocks);
|
||||
setupSvgApi(svgName.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateScripts();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
updateScripts();
|
||||
});
|
||||
|
||||
return {
|
||||
svgData,
|
||||
svgAttributes,
|
||||
svgName,
|
||||
svgContent,
|
||||
calculatedClasses
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
17
app/javascript/components/AppLoading.vue
Normal file
17
app/javascript/components/AppLoading.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="app-loading">
|
||||
Imagine I'm a spinner...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.app-loading {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
60
app/javascript/components/AppModal.vue
Normal file
60
app/javascript/components/AppModal.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div :class="['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 class="close-button" icon="x" aria-label="close" @click="close"></app-icon>
|
||||
</slot>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<slot></slot>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<slot name="footer">
|
||||
</slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
|
||||
const emit = defineEmits(["dismiss"]);
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: String,
|
||||
wide: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const appConfig = useAppConfigStore();
|
||||
const error = computed(() => appConfig.error);
|
||||
|
||||
function close() {
|
||||
emit("dismiss");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.close-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
77
app/javascript/components/AppNavbar.vue
Normal file
77
app/javascript/components/AppNavbar.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<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="/foods" 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 v-if="isLoggedIn" to="/tasks" class="navbar-item">Tasks</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 setup>
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
import UserLogin from "./UserLogin";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
import { swUpdate } from "../lib/ServiceWorker";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const appConfig = useAppConfigStore();
|
||||
const menuActive = ref(false);
|
||||
const route = useRoute();
|
||||
const { isAdmin, isLoggedIn, updateAvailable, user } = storeToRefs(appConfig);
|
||||
|
||||
function updateApp() {
|
||||
swUpdate();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route, appConfig.user],
|
||||
() => menuActive.value = false
|
||||
);
|
||||
|
||||
</script>
|
91
app/javascript/components/AppPager.vue
Normal file
91
app/javascript/components/AppPager.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<nav v-show="totalPages > 1 || showWithSinglePage" class="pagination" role="navigation" :aria-label="pagedItemName + ' page navigation'">
|
||||
<a :class="{'pagination-previous': true, 'is-disabled': isFirstPage}" :title="isFirstPage ? 'This is the first page' : ''" @click.prevent="changePage(currentPage - 1)">Previous</a>
|
||||
<a :class="{'pagination-next': true, 'is-disabled': isLastPage}" :title="isLastPage ? 'This is the last page' : ''" @click.prevent="changePage(currentPage + 1)">Next page</a>
|
||||
<ul class="pagination-list">
|
||||
<li v-for="page in pageItems" :key="page">
|
||||
<a v-if="page > 0" class="pagination-link" :class="{'is-current': page === currentPage}" href="#" @click.prevent="changePage(page)">{{page}}</a>
|
||||
<span v-else class="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
const emit = defineEmits(["changePage"]);
|
||||
|
||||
const props = defineProps({
|
||||
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
|
||||
},
|
||||
|
||||
showWithSinglePage: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const pageItems = computed(() => {
|
||||
const items = new Set();
|
||||
|
||||
for (let x = 0; x < props.pageOuterWindow; x++) {
|
||||
items.add(x + 1);
|
||||
items.add(props.totalPages - x);
|
||||
}
|
||||
|
||||
const start = props.currentPage - Math.ceil(props.pageWindow / 2);
|
||||
const end = props.currentPage + Math.floor(props.pageWindow / 2);
|
||||
|
||||
for (let x = start; x <= end; x++) {
|
||||
items.add(x);
|
||||
}
|
||||
|
||||
let emptySpace = -1;
|
||||
const finalList = [];
|
||||
|
||||
[...items.values()].filter(p => p > 0 && p <= props.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;
|
||||
});
|
||||
|
||||
const isLastPage = computed(() => props.currentPage === props.totalPages);
|
||||
const isFirstPage = computed(() => props.currentPage === 1);
|
||||
|
||||
function changePage(idx) {
|
||||
emit("changePage", idx);
|
||||
}
|
||||
|
||||
</script>
|
69
app/javascript/components/AppProgressBar.vue
Normal file
69
app/javascript/components/AppProgressBar.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="progress-bar" :style="progressStyle"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
import TWEEN from '@tweenjs/tween.js';
|
||||
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
|
||||
|
||||
const appConfig = useAppConfigStore();
|
||||
|
||||
const showProgress = ref(false);
|
||||
const loadingPercent = ref(0);
|
||||
let animation = null;
|
||||
|
||||
const progressStyle = computed(() => {
|
||||
return {
|
||||
opacity: showProgress.value ? "1" : "0",
|
||||
width: `${loadingPercent.value}%`,
|
||||
height: "4px"
|
||||
};
|
||||
});
|
||||
|
||||
watch(() => appConfig.isLoading, val => {
|
||||
if (val) {
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
function start() {
|
||||
if (!animation) {
|
||||
showProgress.value = true;
|
||||
animation = new TWEEN.Tween({ percent: 0 }, useGlobalTweenGroup())
|
||||
.to({ percent: 90 })
|
||||
.easing(TWEEN.Easing.Quartic.Out)
|
||||
.duration(3000)
|
||||
.onUpdate(({ percent }) => { loadingPercent.value = percent; })
|
||||
.onComplete(({ percent }) => {})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (animation) {
|
||||
showProgress.value = false;
|
||||
animation.stop();
|
||||
animation = null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@use "bulma/sass/utilities" as bulma;
|
||||
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999999;
|
||||
background-color: bulma.$blue;
|
||||
transition: width 0.1s, opacity 0.3s;
|
||||
}
|
||||
</style>
|
124
app/javascript/components/AppRating.vue
Normal file
124
app/javascript/components/AppRating.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<span ref="wrapper" class="rating" @click="handleClick" @mousemove="handleMousemove" @mouseleave="handleMouseleave">
|
||||
<span class="set empty-set">
|
||||
<app-iconic-icon v-for="i in starCount" :key="i" icon="star-empty" size="md"></app-iconic-icon>
|
||||
</span>
|
||||
<span class="set filled-set" :style="filledStyle">
|
||||
<app-iconic-icon v-for="i in starCount" :key="i" icon="star" size="md"></app-iconic-icon>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const props = defineProps({
|
||||
starCount: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
|
||||
readonly: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
step: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 0.5
|
||||
},
|
||||
|
||||
modelValue: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryValue = ref(null);
|
||||
const ratingPercent = computed(() => ((props.modelValue || 0) / props.starCount) * 100.0);
|
||||
const wrapperEl = useTemplateRef("wrapper");
|
||||
|
||||
const temporaryPercent = computed(() => {
|
||||
if (temporaryValue.value !== null) {
|
||||
return (temporaryValue.value / props.starCount) * 100.0;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filledStyle = computed(() => {
|
||||
const width = temporaryPercent.value || ratingPercent.value;
|
||||
return {
|
||||
width: width + "%"
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(evt) {
|
||||
if (temporaryValue.value !== null) {
|
||||
emit("update:modelValue", temporaryValue.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMousemove(evt) {
|
||||
if (props.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperBox = wrapperEl.value.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 = props.starCount / props.step;
|
||||
const filledSteps = Math.round(totalSteps * filledRatio);
|
||||
|
||||
temporaryValue.value = filledSteps * props.step;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseleave(evt) {
|
||||
temporaryValue.value = null;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@use "bulma/sass/utilities" as bulma;
|
||||
|
||||
span.rating {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.set {
|
||||
white-space: nowrap;
|
||||
|
||||
svg.iconic {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-set {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.filled-set {
|
||||
color: bulma.$yellow;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
51
app/javascript/components/AppSearchText.vue
Normal file
51
app/javascript/components/AppSearchText.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input type="text" class="input" :placeholder="placeholder" :value="text === null ? '' : text" @input="userUpdateText($event.target.value)">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref } from "vue";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
|
||||
modelValue: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
const text = ref(null);
|
||||
|
||||
const triggerInput = debounce(function() {
|
||||
emit("update:modelValue", text.value);
|
||||
},
|
||||
250,
|
||||
{ leading: false, trailing: true })
|
||||
|
||||
function userUpdateText(newText) {
|
||||
if (text.value !== newText) {
|
||||
text.value = newText;
|
||||
triggerInput();
|
||||
}
|
||||
}
|
||||
|
||||
function propUpdateText(newText) {
|
||||
if (text.value === null && text.value !== newText) {
|
||||
text.value = newText;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
72
app/javascript/components/AppTagEditor.vue
Normal file
72
app/javascript/components/AppTagEditor.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<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 modelValue" :key="t" class="tag">{{t}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import {computed, nextTick, ref, useTemplateRef} from "vue";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const hasFocus = ref(false);
|
||||
const tagText = computed(() => props.modelValue.join(" "));
|
||||
const inputElement = useTemplateRef("input");
|
||||
|
||||
function inputHandler(el) {
|
||||
let str = el.target.value;
|
||||
checkInput(str);
|
||||
nextTick(() => {
|
||||
el.target.value = str;
|
||||
});
|
||||
}
|
||||
|
||||
function checkInput(str) {
|
||||
if (hasFocus.value) {
|
||||
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 (!arraysEqual(newTags, props.modelValue)) {
|
||||
emit("update:modelValue", newTags);
|
||||
}
|
||||
}
|
||||
|
||||
function getFocus() {
|
||||
hasFocus.value = true;
|
||||
}
|
||||
|
||||
function loseFocus() {
|
||||
hasFocus.value = false;
|
||||
checkInput(inputElement.value.value);
|
||||
}
|
||||
|
||||
function 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>
|
80
app/javascript/components/AppTextField.vue
Normal file
80
app/javascript/components/AppTextField.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label v-if="label.length" class="label is-small-mobile">{{ label }}</label>
|
||||
<div :class="controlClasses">
|
||||
<textarea v-if="isTextarea" :class="inputClasses" v-model="model" :disabled="disabled"></textarea>
|
||||
<input v-else :type="type" :class="inputClasses" v-model="model" :disabled="disabled">
|
||||
<app-icon class="is-right" icon="warning" v-if="validationError !== null"></app-icon>
|
||||
</div>
|
||||
<p v-if="helpMessage !== null" :class="helpClasses">
|
||||
{{ helpMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
modelValue: {
|
||||
required: false,
|
||||
type: [String, Number],
|
||||
default: ""
|
||||
},
|
||||
type: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: "text"
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
validationError: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: [String, Number],
|
||||
default: ""
|
||||
});
|
||||
|
||||
const isTextarea = computed(() => props.type === "textarea");
|
||||
const controlClasses = computed(() => [
|
||||
"control",
|
||||
{
|
||||
"has-icons-right": props.validationError !== null
|
||||
}
|
||||
]);
|
||||
|
||||
const inputClasses = computed(() =>[
|
||||
"is-small-mobile",
|
||||
{
|
||||
"textarea": isTextarea.value,
|
||||
"input": !isTextarea.value,
|
||||
"is-danger": props.validationError !== null
|
||||
}
|
||||
]);
|
||||
|
||||
const helpMessage = computed(() => props.validationError);
|
||||
const helpClasses = computed(() => [
|
||||
"help",
|
||||
{
|
||||
"is-danger": props.validationError !== null
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
19
app/javascript/components/AppValidationErrors.vue
Normal file
19
app/javascript/components/AppValidationErrors.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="notification is-danger" v-if="errors !== null" v-for="(errs, prop) in errors">
|
||||
{{ prop }}: {{ errs.join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
errors: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: {}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
233
app/javascript/components/FoodEdit.vue
Normal file
233
app/javascript/components/FoodEdit.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="title">{{action}} {{food.name || "[Unnamed Food]"}}</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="food.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>{{food.ndbn}}</span></button>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<app-autocomplete
|
||||
:inputClass="'is-small-mobile'"
|
||||
ref="autocomplete"
|
||||
v-model="food.usda_food_name"
|
||||
:minLength="2"
|
||||
valueAttribute="name"
|
||||
labelAttribute="description"
|
||||
key-attribute="ndbn"
|
||||
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="food.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="food.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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Grams</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="unit in visibleFoodUnits" :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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="message">
|
||||
<div class="message-header">
|
||||
NDBN Units
|
||||
</div>
|
||||
|
||||
<div class="message-body">
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Grams</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="unit in food.ndbn_units">
|
||||
<td>{{unit.description}}</td>
|
||||
<td>{{unit.gram_weight}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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="food[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 setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
import api from "../lib/Api";
|
||||
import { mapState } from "pinia";
|
||||
import { useNutrientStore } from "../stores/nutrient";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const nutrientStore = useNutrientStore();
|
||||
const nutrients = computed(() => nutrientStore.nutrientList);
|
||||
const { loadResource } = useLoadResource();
|
||||
|
||||
const props = defineProps({
|
||||
food: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
validationErrors: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
action: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: "Editing"
|
||||
}
|
||||
});
|
||||
|
||||
const visibleFoodUnits = computed(() => props.food.food_units.filter(iu => iu._destroy !== true));
|
||||
const hasNdbn = computed(() => props.food.ndbn !== null);
|
||||
|
||||
function addUnit() {
|
||||
props.food.food_units.push({
|
||||
id: null,
|
||||
name: null,
|
||||
gram_weight: null
|
||||
});
|
||||
}
|
||||
|
||||
function removeUnit(unit) {
|
||||
if (unit.id) {
|
||||
unit._destroy = true;
|
||||
} else {
|
||||
const idx = props.food.food_units.findIndex(i => i === unit);
|
||||
props.food.food_units.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function removeNdbn() {
|
||||
props.food.ndbn = null;
|
||||
props.food.usda_food_name = null;
|
||||
props.food.ndbn_units = [];
|
||||
}
|
||||
|
||||
function 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("")
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function searchItemSelected(food) {
|
||||
props.food.ndbn = food.ndbn;
|
||||
props.food.usda_food_name = food.name;
|
||||
props.food.ndbn_units = [];
|
||||
|
||||
loadResource(
|
||||
api.postIngredientSelectNdbn(props.food)
|
||||
.then(i => Object.assign(props.food, i))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.unit-label {
|
||||
width: 3em;
|
||||
}
|
||||
|
||||
</style>
|
74
app/javascript/components/FoodShow.vue
Normal file
74
app/javascript/components/FoodShow.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="title">
|
||||
{{food.name}}
|
||||
</h3>
|
||||
|
||||
<div class="message" v-if="food.ndbn">
|
||||
<div class="message-header">
|
||||
<span>USDA NDBN #{{ food.ndbn }}</span>
|
||||
</div>
|
||||
|
||||
<div class="message-body">
|
||||
<a :href="'https://ndb.nal.usda.gov/ndb/foods/show/' + food.ndbn">USDA DB Entry</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="message-header">
|
||||
Custom Units
|
||||
</div>
|
||||
|
||||
<div class="message-body">
|
||||
<ul>
|
||||
<li v-for="fu in food.food_units" :key="fu.id">
|
||||
{{fu.name}}: {{fu.gram_weight}} grams
|
||||
</li>
|
||||
</ul>
|
||||
</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="true" v-model="food[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 setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
import { useNutrientStore } from "../stores/nutrient";
|
||||
|
||||
const props = defineProps({
|
||||
food: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
const nutrientStore = useNutrientStore();
|
||||
const nutrients = computed(() => nutrientStore.nutrientList);
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
53
app/javascript/components/LogEdit.vue
Normal file
53
app/javascript/components/LogEdit.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<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 setup>
|
||||
|
||||
import RecipeEdit from "./RecipeEdit";
|
||||
|
||||
const props = defineProps({
|
||||
log: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
validationErrors: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
61
app/javascript/components/LogShow.vue
Normal file
61
app/javascript/components/LogShow.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<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 :model-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 setup>
|
||||
|
||||
import RecipeShow from "./RecipeShow";
|
||||
|
||||
const props = defineProps({
|
||||
log: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
47
app/javascript/components/NoteEdit.vue
Normal file
47
app/javascript/components/NoteEdit.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<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 setup>
|
||||
|
||||
import { computed } from "vue";
|
||||
|
||||
const emit = defineEmits(["save", "cancel"]);
|
||||
const props = defineProps({
|
||||
note: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => props.note?.content?.length);
|
||||
|
||||
function save() {
|
||||
emit("save", props.note);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
265
app/javascript/components/RecipeEdit.vue
Normal file
265
app/javascript/components/RecipeEdit.vue
Normal file
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="recipe-edit">
|
||||
<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>
|
||||
|
||||
<recipe-edit-ingredient-editor :ingredients="recipe.ingredients"></recipe-edit-ingredient-editor>
|
||||
|
||||
<br>
|
||||
<div class="field">
|
||||
<label class="label title is-4">Directions <button @click="isDescriptionHelpOpen = true" class="button is-small is-link"><app-icon icon="question-mark"></app-icon></button></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 class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="recipe.is_ingredient" />
|
||||
Is Ingredient
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<app-modal title="Markdown Help" :open="isDescriptionHelpOpen" @dismiss="isDescriptionHelpOpen = false">
|
||||
<p>
|
||||
The description editor uses <a href="https://www.markdownguide.org/cheat-sheet/">Markdown</a>. Follow the link for a full
|
||||
description of the syntax, but below is a quick reference.
|
||||
</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Style</th>
|
||||
<th>Syntax</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Heading</td>
|
||||
<td>
|
||||
<pre>
|
||||
# Biggest Heading
|
||||
## Smaller Heading
|
||||
###### Smallest Heading
|
||||
</pre>
|
||||
</td>
|
||||
<td class="content">
|
||||
<h1>Biggest Heading</h1>
|
||||
<h2>Smaller Heading</h2>
|
||||
<h6>Smallest Heading</h6>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Numbered Lists</td>
|
||||
<td>
|
||||
<pre>
|
||||
1. First Item
|
||||
1. Second Item
|
||||
1. subitem A
|
||||
1. subitem B
|
||||
1. Thrid Item
|
||||
</pre>
|
||||
</td>
|
||||
<td class="content">
|
||||
<ol>
|
||||
<li>First Item</li>
|
||||
<li>Second Item
|
||||
<ol>
|
||||
<li>subitem A</li>
|
||||
<li>subitem B</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Thrid Item</li>
|
||||
</ol>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lists</td>
|
||||
<td>
|
||||
<pre>
|
||||
* First Item
|
||||
* Second Item
|
||||
* subitem A
|
||||
* subitem B
|
||||
* Third Item
|
||||
</pre>
|
||||
</td>
|
||||
<td class="content">
|
||||
<ul>
|
||||
<li>First Item</li>
|
||||
<li>Second Item
|
||||
|
||||
<ul>
|
||||
<li>subitem A</li>
|
||||
<li>subitem B</li>
|
||||
</ul></li>
|
||||
<li>Third Item</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Basic Styles</td>
|
||||
<td>
|
||||
<pre>
|
||||
*italics*
|
||||
**bold**
|
||||
***bold italics***
|
||||
_underline_
|
||||
==highlight==
|
||||
</pre>
|
||||
</td>
|
||||
<td class="content">
|
||||
<p>
|
||||
<em>italics</em><br>
|
||||
<strong>bold</strong><br>
|
||||
<strong><em>bold italics</em></strong><br>
|
||||
<u>underline</u><br>
|
||||
<mark>highlight</mark>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="title is-3">Basic Example</h3>
|
||||
<h5 class="subtitle is=3">Input</h5>
|
||||
<pre>
|
||||
## For the dough
|
||||
1. Mix dry ingredients
|
||||
1. Fold in egg whites
|
||||
1. Sprinkle on sardines
|
||||
|
||||
## For the sauce
|
||||
1. Blend clams ==thoroughly==
|
||||
1. Melt beef lard and add clam slurry
|
||||
|
||||
### Optional (Toppings)
|
||||
* Raw onion
|
||||
* Sliced hard boiled eggs
|
||||
</pre>
|
||||
|
||||
<h5 class="subtitle is=3">Output</h5>
|
||||
|
||||
<div class="content box">
|
||||
<h2>For the dough</h2>
|
||||
|
||||
<ol>
|
||||
<li>Mix dry ingredients</li>
|
||||
<li>Fold in egg whites</li>
|
||||
<li>Sprinkle on sardines</li>
|
||||
</ol>
|
||||
|
||||
<h2>For the sauce</h2>
|
||||
|
||||
<ol>
|
||||
<li>Blend clams <mark>thoroughly</mark></li>
|
||||
<li>Melt beef lard and add clam slurry</li>
|
||||
</ol>
|
||||
|
||||
<h3>Optional (Toppings)</h3>
|
||||
|
||||
<ul>
|
||||
<li>Raw onion</li>
|
||||
<li>Sliced hard boiled eggs</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</app-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref, useTemplateRef, watch } from "vue";
|
||||
import { useAutosize } from "../lib/useAutosize";
|
||||
import debounce from "lodash/debounce";
|
||||
import api from "../lib/Api";
|
||||
import RecipeEditIngredientEditor from "./RecipeEditIngredientEditor";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
forLogging: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const stepTextArea = useTemplateRef("step_text_area");
|
||||
const stepPreviewCache = ref(null);
|
||||
const isDescriptionHelpOpen = ref(false);
|
||||
|
||||
useAutosize(stepTextArea);
|
||||
|
||||
const stepPreview = computed(() => {
|
||||
if (stepPreviewCache.value === null) {
|
||||
return props.recipe.rendered_steps;
|
||||
} else {
|
||||
return stepPreviewCache.value;
|
||||
}
|
||||
});
|
||||
|
||||
const updatePreview = debounce(function() {
|
||||
api.postPreviewSteps(props.recipe.step_text)
|
||||
.then(data => stepPreviewCache.value = data.rendered_steps)
|
||||
.catch(err => stepPreviewCache.value = "?? Error ??");
|
||||
}, 750);
|
||||
|
||||
watch(
|
||||
() => props.recipe.step_text,
|
||||
() => updatePreview()
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.recipe-edit {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.directions-input {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
209
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
209
app/javascript/components/RecipeEditIngredientEditor.vue
Normal file
@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="title is-4">
|
||||
Ingredients
|
||||
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
|
||||
</h3>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Unit</th>
|
||||
<th>Name</th>
|
||||
<th>Prep</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in bulkIngredientPreview">
|
||||
<td>{{i.quantity}}</td>
|
||||
<td>{{i.units}}</td>
|
||||
<td>{{i.name}}</td>
|
||||
<td>{{i.preparation}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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" @deleteFood="deleteFood"></recipe-edit-ingredient-item>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button is-primary" @click="addIngredient">Add Ingredient</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { useMediaQueryStore } from "../stores/mediaQuery";
|
||||
import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
|
||||
|
||||
const mediaQueryStore = useMediaQueryStore();
|
||||
|
||||
const props = defineProps({
|
||||
ingredients: {
|
||||
required: true,
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const isBulkEditing = ref(false);
|
||||
const bulkEditText = ref(null);
|
||||
const isMobile = computed(() => mediaQueryStore.mobile);
|
||||
const visibleIngredients = computed(() => props.ingredients.filter(i => i._destroy !== true));
|
||||
|
||||
const bulkIngredientPreview = computed(() => {
|
||||
if (bulkEditText.value === null || bulkEditText.value === "") {
|
||||
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 = bulkEditText.value.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;
|
||||
});
|
||||
|
||||
function createIngredient() {
|
||||
const sort_orders = props.ingredients.map(i => i.sort_order);
|
||||
sort_orders.push(0);
|
||||
const next_sort_order = Math.max(...sort_orders) + 5;
|
||||
|
||||
return {
|
||||
id: null,
|
||||
quantity: null,
|
||||
units: null,
|
||||
name: null,
|
||||
preparation: null,
|
||||
ingredient_id: null,
|
||||
sort_order: next_sort_order
|
||||
};
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
props.ingredients.push(createIngredient());
|
||||
}
|
||||
|
||||
function deleteFood(food) {
|
||||
if (food.id) {
|
||||
food._destroy = true;
|
||||
} else {
|
||||
const idx = props.ingredients.findIndex(i => i === food);
|
||||
props.ingredients.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkEditIngredients() {
|
||||
isBulkEditing.value = true;
|
||||
|
||||
let text = [];
|
||||
|
||||
for (let item of visibleIngredients.value) {
|
||||
text.push(
|
||||
item.quantity + " " +
|
||||
(item.units || "-") + " " +
|
||||
(item.name.indexOf(",") >= 0 ? item.name + "|" : item.name) +
|
||||
(item.preparation ? (", " + item.preparation) : "") +
|
||||
(item.id ? (" [" + item.id + "]") : "")
|
||||
);
|
||||
}
|
||||
|
||||
bulkEditText.value = text.join("\n");
|
||||
}
|
||||
|
||||
function cancelBulkEditing() {
|
||||
isBulkEditing.value = false;
|
||||
}
|
||||
|
||||
function saveBulkEditing() {
|
||||
const parsed = bulkIngredientPreview.value.filter(i => i !== null);
|
||||
const existing = [...props.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 = 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});
|
||||
}
|
||||
|
||||
props.ingredients.splice(0);
|
||||
let sortIdx = 0;
|
||||
for (let n of newList) {
|
||||
n.sort_order = sortIdx++;
|
||||
props.ingredients.push(n);
|
||||
}
|
||||
|
||||
isBulkEditing.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.bulk-input {
|
||||
|
||||
textarea {
|
||||
height: 100%;
|
||||
min-height: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
109
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
109
app/javascript/components/RecipeEditIngredientItem.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="columns is-mobile edit-ingredient-item">
|
||||
<div class="column">
|
||||
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-half-mobile is-2-tablet">
|
||||
<span class="label is-small-mobile" v-if="showLabels">Quantity</span>
|
||||
<input class="input is-small-mobile" type="text" v-model="ingredient.quantity">
|
||||
</div>
|
||||
<div class="column is-half-mobile is-3-tablet">
|
||||
<span class="label is-small-mobile" v-if="showLabels">Units</span>
|
||||
<input class="input is-small-mobile" type="text" v-model="ingredient.units">
|
||||
</div>
|
||||
<div class="column is-half-mobile is-3-tablet">
|
||||
<span class="label is-small-mobile" v-if="showLabels">Name</span>
|
||||
|
||||
<app-autocomplete
|
||||
:inputClass="{'is-small-mobile': true, 'is-success': ingredient.ingredient_id !== null}"
|
||||
ref="autocomplete"
|
||||
v-model="ingredient.name"
|
||||
:minLength="2"
|
||||
valueAttribute="name"
|
||||
labelAttribute="name"
|
||||
placeholder=""
|
||||
@inputClick="nameClick"
|
||||
@optionSelected="searchItemSelected"
|
||||
:onGetOptions="updateSearchItems"
|
||||
>
|
||||
</app-autocomplete>
|
||||
</div>
|
||||
<div class="column is-half-mobile is-4-tablet">
|
||||
<span class="label is-small-mobile" v-if="showLabels">Preparation</span>
|
||||
<input class="input is-small-mobile" type="text" v-model="ingredient.preparation">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<span class="label is-small-mobile" v-if="showLabels"> </span>
|
||||
<button type="button" class="button is-danger is-small" @click="deleteFood(ingredient)">
|
||||
<app-icon icon="x" size="md"></app-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { useTemplateRef, watch } from "vue";
|
||||
import api from "../lib/Api";
|
||||
|
||||
const emit = defineEmits(["deleteFood"]);
|
||||
const props = defineProps({
|
||||
ingredient: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
showLabels: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const autocompleteElement = useTemplateRef("autocomplete");
|
||||
|
||||
watch(props.ingredient, (val) => {
|
||||
if (props.ingredient.ingredient && props.ingredient.ingredient.name !== val) {
|
||||
props.ingredient.ingredient_id = null;
|
||||
props.ingredient.ingredient = null;
|
||||
}
|
||||
});
|
||||
|
||||
function deleteFood(ingredient) {
|
||||
emit("deleteFood", ingredient);
|
||||
}
|
||||
|
||||
function updateSearchItems(text) {
|
||||
return api.getSearchIngredients(text);
|
||||
}
|
||||
|
||||
function searchItemSelected(ingredient) {
|
||||
props.ingredient.ingredient_id = ingredient.id;
|
||||
props.ingredient.ingredient = ingredient;
|
||||
props.ingredient.name = ingredient.name;
|
||||
}
|
||||
|
||||
function nameClick() {
|
||||
if (props.ingredient.ingredient_id === null && props.ingredient.name !== null && props.ingredient.name.length > 2) {
|
||||
autocompleteElement.updateOptions(props.ingredient.name);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@use "bulma/sass/utilities" as bulma;
|
||||
|
||||
.edit-ingredient-item {
|
||||
border-bottom: solid 1px bulma.$grey-light;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
271
app/javascript/components/RecipeShow.vue
Normal file
271
app/javascript/components/RecipeShow.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<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">
|
||||
<a v-if="isSourceUrl" :href="sourceUrl">{{sourceText}}</a>
|
||||
<span v-else>{{sourceText}}</span>
|
||||
</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>
|
||||
<app-dropdown :open="addToTasksMenuOpen" label="Add to list" button-class="is-small is-primary" @open="addToTasksMenuOpen = true" @close="addToTasksMenuOpen = false">
|
||||
<button class="button primary" v-for="tl in taskStore.taskLists" :key="tl.id" @click="addRecipeToList(tl)">
|
||||
{{tl.name}}
|
||||
</button>
|
||||
</app-dropdown>
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name">
|
||||
<td>{{nutrient.label}}</td>
|
||||
<td>{{ roundValue(nutrient.value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 setup>
|
||||
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import api from "../lib/Api";
|
||||
import { useTaskStore } from "../stores/task";
|
||||
|
||||
const taskStore = useTaskStore();
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
const showNutrition = ref(false);
|
||||
const showConvertDialog = ref(false);
|
||||
const addToTasksMenuOpen = ref(false);
|
||||
|
||||
const scaleValue = ref('1');
|
||||
const systemConvertValue = ref('');
|
||||
const unitConvertValue = ref('');
|
||||
|
||||
const scaleOptions = [
|
||||
'1/4',
|
||||
'1/3',
|
||||
'1/2',
|
||||
'2/3',
|
||||
'3/4',
|
||||
'1',
|
||||
'1 1/2',
|
||||
'2',
|
||||
'3',
|
||||
'4'
|
||||
];
|
||||
|
||||
const timeDisplay = computed(() => {
|
||||
let a = formatMinutes(props.recipe.active_time);
|
||||
const t = formatMinutes(props.recipe.total_time);
|
||||
|
||||
if (a) {
|
||||
a = ` (${a} active)`;
|
||||
}
|
||||
|
||||
return t + a;
|
||||
});
|
||||
|
||||
const sourceUrl = computed(() => {
|
||||
try {
|
||||
return new URL(props.recipe.source);
|
||||
} catch(err) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const isSourceUrl = computed(() => sourceUrl.value !== null);
|
||||
const sourceText = computed(() => isSourceUrl.value ? sourceUrl.value.host : props.recipe.source);
|
||||
|
||||
watch(props.recipe, (r) => {
|
||||
if (r) {
|
||||
scaleValue.value = r.converted_scale || '1';
|
||||
systemConvertValue.value = r.converted_system;
|
||||
unitConvertValue.value = r.converted_unit;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
taskStore.ensureTaskLists();
|
||||
});
|
||||
|
||||
function addRecipeToList(list) {
|
||||
api.addRecipeToTaskList(list.id, props.recipe.id)
|
||||
.then(() => {
|
||||
taskStore.setCurrentTaskList(list);
|
||||
router.push({name: 'task_lists'})
|
||||
});
|
||||
}
|
||||
|
||||
function convert() {
|
||||
showConvertDialog.value = false;
|
||||
router.push({name: 'recipe', query: { scale: scaleValue.value, system: systemConvertValue.value, unit: unitConvertValue.value }});
|
||||
}
|
||||
|
||||
function roundValue(v) {
|
||||
return parseFloat(v).toFixed(2);
|
||||
}
|
||||
|
||||
function 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>
|
71
app/javascript/components/TaskItemEdit.vue
Normal file
71
app/javascript/components/TaskItemEdit.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="columns task-item-edit">
|
||||
|
||||
<div class="field column">
|
||||
<label class="label is-small">Name</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" placeholder="Name" v-model="taskItem.name" @keydown="inputKeydown" ref="nameInput">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column">
|
||||
<label class="label is-small">Quantity</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" placeholder="Qty" v-model="taskItem.quantity" @keydown="inputKeydown">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column">
|
||||
<div class="control">
|
||||
<button class="button is-primary" @click="save">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { onMounted, useTemplateRef } from "vue";
|
||||
|
||||
const emit = defineEmits(["save"]);
|
||||
const props = defineProps({
|
||||
taskItem: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
const nameElement = useTemplateRef("nameInput");
|
||||
|
||||
onMounted(() => focus());
|
||||
|
||||
function inputKeydown(evt) {
|
||||
switch (evt.key) {
|
||||
case "Enter":
|
||||
evt.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit("save", props.taskItem);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
nameElement.value.focus();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.task-item-edit {
|
||||
|
||||
}
|
||||
|
||||
</style>
|
193
app/javascript/components/TaskItemList.vue
Normal file
193
app/javascript/components/TaskItemList.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<div class="panel">
|
||||
<p class="panel-heading">
|
||||
{{taskList.name}} ({{completedItemCount}} / {{taskList.task_items.length}})
|
||||
</p>
|
||||
|
||||
<div class="panel-block">
|
||||
<button class="button is-fullwidth is-primary" @click="toggleShowAddItem">{{ showAddItem ? 'Done' : 'New Item' }}</button>
|
||||
</div>
|
||||
|
||||
<app-expand-transition>
|
||||
<div class="panel-block" v-if="showAddItem">
|
||||
<task-item-edit @save="save" :task-item="newItem" ref="itemEdit"></task-item-edit>
|
||||
</div>
|
||||
</app-expand-transition>
|
||||
|
||||
<transition-group tag="div" name="list-item-move">
|
||||
<a v-for="i in taskItems" :key="i.id" @click="toggleItem(i)" class="panel-block">
|
||||
<div class="check">
|
||||
<app-icon v-if="i.completed" icon="check"></app-icon>
|
||||
<span class="icon" v-else></span>
|
||||
</div>
|
||||
<span>{{ i.quantity }} {{ i.name }}</span>
|
||||
</a>
|
||||
</transition-group>
|
||||
|
||||
<app-expand-transition>
|
||||
<div class="panel-block" v-if="uncompletedItemCount > 0">
|
||||
<button class="button is-fullwidth is-link" @click="completeAllItems">
|
||||
<span class="check">
|
||||
<app-icon icon="check"></app-icon>
|
||||
</span>
|
||||
<span>Check All</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-expand-transition>
|
||||
<app-expand-transition>
|
||||
<div class="panel-block" v-if="completedItemCount > 0">
|
||||
<button class="button is-fullwidth is-link" @click="unCompleteAllItems">
|
||||
<span class="check">
|
||||
<span class="icon"></span>
|
||||
</span>
|
||||
<span>Uncheck All</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-expand-transition>
|
||||
<app-expand-transition>
|
||||
<div class="panel-block" v-if="completedItemCount > 0">
|
||||
<button class="button is-fullwidth is-link" @click="deleteCompletedItems">
|
||||
<app-icon icon="x" class="is-text-danger"></app-icon>
|
||||
<span>Clear Completed</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-expand-transition>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
import * as Errors from '../lib/Errors';
|
||||
import { useTaskStore } from "../stores/task";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
import TaskItemEdit from "./TaskItemEdit";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const taskStore = useTaskStore();
|
||||
const itemEditElement = useTemplateRef("itemEdit");
|
||||
|
||||
const props = defineProps({
|
||||
taskList: {
|
||||
required: true,
|
||||
type: Object
|
||||
}
|
||||
});
|
||||
|
||||
const showAddItem = ref(false);
|
||||
const newItem = ref(null);
|
||||
const newItemValidationErrors = ref({});
|
||||
|
||||
const completedTaskItems = computed(() => (props.taskList ? props.taskList.task_items : []).filter(i => i.completed));
|
||||
const uncompletedTaskItems = computed(() => (props.taskList ? props.taskList.task_items : []).filter(i => !i.completed));
|
||||
const completedItemCount = computed(() => completedTaskItems.value.length);
|
||||
const uncompletedItemCount = computed(() => uncompletedTaskItems.value.length);
|
||||
const taskItems = computed(() => uncompletedTaskItems.value.concat(completedTaskItems.value));
|
||||
|
||||
function newItemTemplate() {
|
||||
return {
|
||||
task_list_id: null,
|
||||
name: '',
|
||||
quantity: '',
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
function save() {
|
||||
newItem.value.task_list_id = props.taskList.id;
|
||||
loadResource(
|
||||
taskStore.createTaskItem(newItem.value)
|
||||
.then(() => {
|
||||
newItem.value = newItemTemplate();
|
||||
itemEditElement.value.focus();
|
||||
})
|
||||
.catch(Errors.onlyFor(Errors.ApiValidationError, err => newItemValidationErrors.value = err.validationErrors()))
|
||||
)
|
||||
}
|
||||
|
||||
function toggleItem(i) {
|
||||
loadResource(
|
||||
taskStore.completeTaskItems({
|
||||
taskList: props.taskList,
|
||||
taskItems: [i],
|
||||
completed: !i.completed
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function toggleShowAddItem() {
|
||||
newItem.value = newItemTemplate();
|
||||
showAddItem.value = !showAddItem.value;
|
||||
}
|
||||
|
||||
function completeAllItems() {
|
||||
const toComplete = props.taskList.task_items.filter(i => !i.completed);
|
||||
loadResource(
|
||||
taskStore.completeTaskItems({
|
||||
taskList: props.taskList,
|
||||
taskItems: toComplete,
|
||||
completed: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function unCompleteAllItems() {
|
||||
const toUnComplete = props.taskList.task_items.filter(i => i.completed);
|
||||
loadResource(
|
||||
taskStore.completeTaskItems({
|
||||
taskList: props.taskList,
|
||||
taskItems: toUnComplete,
|
||||
completed: false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function deleteCompletedItems() {
|
||||
loadResource(
|
||||
taskStore.deleteTaskItems({
|
||||
taskList: props.taskList,
|
||||
taskItems: props.taskList.task_items.filter(i => i.completed)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.columns {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
margin-right: 1.5rem;
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
bottom: -3px;
|
||||
right: -3px;
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
69
app/javascript/components/TaskListDropdownItem.vue
Normal file
69
app/javascript/components/TaskListDropdownItem.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
|
||||
<div class="dropdown-item" @mouseover="hovering = true" @mouseleave="hovering = false" :class="{hovered: hovering, 'is-active': active}" @click="selectList">
|
||||
<span>{{taskList.name}} ({{ taskList.task_items.length }})</span>
|
||||
<button @click.stop="confirmingDelete = true" class="button is-small is-danger is-pulled-right"><app-icon icon="x" size="sm"></app-icon></button>
|
||||
<div class="is-clearfix"></div>
|
||||
|
||||
<app-modal :open="confirmingDelete" :title="'Delete ' + taskList.name + '?'" @dismiss="confirmingDelete = false">
|
||||
<button class="button is-danger" @click="deleteList">Confirm</button>
|
||||
<button class="button is-primary" @click="confirmingDelete = false">Cancel</button>
|
||||
</app-modal>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["select", "delete"]);
|
||||
|
||||
const props = defineProps({
|
||||
taskList: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
active: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const hovering = ref(false);
|
||||
const confirmingDelete = ref(false);
|
||||
|
||||
function selectList() {
|
||||
emit("select", props.taskList);
|
||||
}
|
||||
|
||||
function deleteList() {
|
||||
confirmingDelete.value = false;
|
||||
emit("delete", props.taskList);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@use "bulma/sass/utilities" as bulma;
|
||||
@use 'sass:color';
|
||||
|
||||
div.dropdown-item {
|
||||
cursor: pointer;
|
||||
|
||||
&.hovered {
|
||||
color: bulma.$black;
|
||||
background-color: bulma.$background;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: color.invert(bulma.$link);
|
||||
background-color: bulma.$link;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
46
app/javascript/components/TaskListMiniForm.vue
Normal file
46
app/javascript/components/TaskListMiniForm.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-validation-errors :errors="validationErrors"></app-validation-errors>
|
||||
|
||||
<label class="label is-small">Add New List</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" v-model="taskList.name" @keydown="nameKeydownHandler">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary is-small" @click="save">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const emit = defineEmits(["save"]);
|
||||
|
||||
const props = defineProps({
|
||||
taskList: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
|
||||
validationErrors: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: function() { return {}; }
|
||||
}
|
||||
});
|
||||
|
||||
function save() {
|
||||
emit("save");
|
||||
}
|
||||
|
||||
function nameKeydownHandler(evt) {
|
||||
switch (evt.key) {
|
||||
case "Enter":
|
||||
evt.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
11
app/javascript/components/The404Page.vue
Normal file
11
app/javascript/components/The404Page.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>404!</h1>
|
||||
<p>WTF?</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
|
||||
</script>
|
27
app/javascript/components/TheAboutPage.vue
Normal file
27
app/javascript/components/TheAboutPage.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1 class="title">About</h1>
|
||||
|
||||
<p>
|
||||
A Recipe manager. Source available at <a href="https://source.elbert.us/dan/parsley">https://source.elbert.us</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Parsley is released under the MIT License. All code © Dan Elbert 2024.
|
||||
</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 setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
12
app/javascript/components/TheAdminUserEditor.vue
Normal file
12
app/javascript/components/TheAdminUserEditor.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
52
app/javascript/components/TheAdminUserList.vue
Normal file
52
app/javascript/components/TheAdminUserList.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<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 setup>
|
||||
|
||||
import { onBeforeMount, ref } from "vue";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
import api from "../lib/Api";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const userList = ref([]);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getAdminUserList()
|
||||
.then(list => userList.value = list)
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
107
app/javascript/components/TheCalculator.vue
Normal file
107
app/javascript/components/TheCalculator.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="title">Calculator</h1>
|
||||
|
||||
<div class="box">
|
||||
|
||||
<div class="columns">
|
||||
<app-text-field label="Input" v-model="input" class="column" :validation-error="inputErrors"></app-text-field>
|
||||
<app-text-field label="Output Unit" v-model="outputUnit" class="column" :validation-error="outputUnitErrors"></app-text-field>
|
||||
|
||||
</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>
|
||||
|
||||
<app-text-field label="Density" v-model="density" class="column" :disabled="ingredient !== null" :validation-error="densityErrors"></app-text-field>
|
||||
|
||||
</div>
|
||||
|
||||
<app-text-field label="Output" v-model="output" disabled></app-text-field>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import api from "../lib/Api";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
|
||||
const input = ref("");
|
||||
const outputUnit = ref("");
|
||||
const ingredient_name = ref("");
|
||||
const ingredient = ref(null);
|
||||
const density = ref("");
|
||||
const output = ref("");
|
||||
const errors = ref({});
|
||||
|
||||
const inputErrors = computed(() => getErrors("input"));
|
||||
const outputUnitErrors = computed(() => getErrors("output_unit"));
|
||||
const densityErrors = computed(() => getErrors("density"));
|
||||
|
||||
const updateOutput = debounce(function() {
|
||||
if (input.value && input.value.length > 0) {
|
||||
loadResource(api.getCalculate(input.value, outputUnit.value, ingredient.value ? ingredient.value.ingredient_id : null, density.value)
|
||||
.then(data => {
|
||||
output.value = data.output;
|
||||
errors.value = data.errors;
|
||||
})
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
watch(ingredient_name, function(val) {
|
||||
if (ingredient.value && ingredient.value.name !== val) {
|
||||
ingredient.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[input, outputUnit, density, ingredient],
|
||||
() => updateOutput()
|
||||
);
|
||||
|
||||
function updateSearchItems(text) {
|
||||
return api.getSearchIngredients(text);
|
||||
}
|
||||
|
||||
function searchItemSelected(ingredient) {
|
||||
ingredient.value = ingredient || null;
|
||||
ingredient_name.value = ingredient.name || null;
|
||||
density.value = ingredient.density || null;
|
||||
}
|
||||
|
||||
function getErrors(type) {
|
||||
if (errors.value[type] && errors.value[type].length > 0) {
|
||||
return errors.value[type].join(", ");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
41
app/javascript/components/TheFood.vue
Normal file
41
app/javascript/components/TheFood.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="food === null">
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else>
|
||||
<food-show :food="food"></food-show>
|
||||
</div>
|
||||
|
||||
<router-link v-if="appConfig.isLoggedIn" class="button" :to="{name: 'edit_food', params: { id: foodId }}">Edit</router-link>
|
||||
<router-link class="button" to="/foods">Back</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, onBeforeMount, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import FoodShow from "./FoodShow";
|
||||
import api from "../lib/Api";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const appConfig = useAppConfigStore();
|
||||
const route = useRoute();
|
||||
|
||||
const food = ref(null);
|
||||
const foodId = computed(() => route.params.id);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getFood(foodId.value)
|
||||
.then(data => { food.value = data; return data; })
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
70
app/javascript/components/TheFoodCreator.vue
Normal file
70
app/javascript/components/TheFoodCreator.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<food-edit :food="food" :validation-errors="validationErrors" action="Creating"></food-edit>
|
||||
|
||||
<button type="button" class="button is-primary" @click="save">Save</button>
|
||||
<router-link class="button is-secondary" to="/food">Cancel</router-link>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import FoodEdit from "./FoodEdit";
|
||||
import api from "../lib/Api";
|
||||
import * as Errors from '../lib/Errors';
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const router = useRouter();
|
||||
|
||||
const validationErrors = ref({});
|
||||
const food = reactive({
|
||||
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,
|
||||
food_units: []
|
||||
});
|
||||
|
||||
function save() {
|
||||
validationErrors.value = {}
|
||||
loadResource(
|
||||
api.postFood(food)
|
||||
.then(() => router.push('/foods'))
|
||||
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
53
app/javascript/components/TheFoodEditor.vue
Normal file
53
app/javascript/components/TheFoodEditor.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<div v-if="food === null">
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else>
|
||||
<food-edit :food="food" :validation-errors="validationErrors"></food-edit>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button is-primary" @click="save">Save</button>
|
||||
<router-link class="button is-secondary" to="/foods">Cancel</router-link>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, onBeforeMount, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import FoodEdit from "./FoodEdit";
|
||||
import api from "../lib/Api";
|
||||
import * as Errors from '../lib/Errors';
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const food = ref(null);
|
||||
const validationErrors = ref({});
|
||||
const foodId = computed(() => route.params.id);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getFood(foodId.value)
|
||||
.then(data => { food.value = data; return data; })
|
||||
);
|
||||
});
|
||||
|
||||
function save() {
|
||||
validationErrors.value = {};
|
||||
loadResource(
|
||||
api.patchFood(food.value)
|
||||
.then(() => router.push({name: 'food', params: {id: foodId.value }}))
|
||||
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
131
app/javascript/components/TheFoodList.vue
Normal file
131
app/javascript/components/TheFoodList.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="title">Ingredients</h1>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
|
||||
</div>
|
||||
|
||||
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="food" @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>
|
||||
<transition-group tag="tbody" name="fade" mode="out-in">
|
||||
<tr v-for="i in foods" :key="i.id">
|
||||
<td><router-link :to="{name: 'food', 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>
|
||||
<template v-if="appConfig.isLoggedIn">
|
||||
<router-link class="button" :to="{name: 'edit_food', params: { id: i.id } }">
|
||||
<app-icon icon="pencil"></app-icon>
|
||||
</router-link>
|
||||
<button type="button" class="button is-danger" @click="deleteFood(i)">
|
||||
<app-icon icon="x"></app-icon>
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</transition-group>
|
||||
</table>
|
||||
|
||||
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="food" @changePage="changePage"></app-pager>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
|
||||
</div>
|
||||
|
||||
<app-confirm :open="showConfirmFoodDelete" title="Delete Ingredient?" :message="confirmFoodDeleteMessage" @cancel="foodDeleteCancel" @confirm="foodDeleteConfirm"></app-confirm>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import api from "../lib/Api";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const appConfig = useAppConfigStore();
|
||||
const { loadResource } = useLoadResource();
|
||||
|
||||
const foodData = ref(null);
|
||||
const foodForDeletion = ref(null);
|
||||
const search = reactive({
|
||||
page: 1,
|
||||
per: 25,
|
||||
name: null
|
||||
});
|
||||
|
||||
const foods = computed(() => foodData.value?.foods || []);
|
||||
const totalPages = computed(() => foodData.value?.total_pages || 0);
|
||||
const currentPage = computed(() => foodData.value?.current_page || 0);
|
||||
const showConfirmFoodDelete = computed(() => foodForDeletion.value !== null);
|
||||
const confirmFoodDeleteMessage = computed(() => {
|
||||
if (foodForDeletion.value !== null) {
|
||||
return `Are you sure you want to delete ${foodForDeletion.value.name}?`;
|
||||
} else {
|
||||
return "??";
|
||||
}
|
||||
});
|
||||
|
||||
const getList = debounce(function() {
|
||||
return loadResource(
|
||||
api.getFoodList(search.page, search.per, search.name)
|
||||
.then(data => foodData.value = data)
|
||||
);
|
||||
}, 500, {leading: true, trailing: true});
|
||||
|
||||
watch(search,
|
||||
() => getList(),
|
||||
{
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
function changePage(idx) {
|
||||
search.page = idx;
|
||||
}
|
||||
|
||||
function deleteFood(food) {
|
||||
foodForDeletion.value = food;
|
||||
}
|
||||
|
||||
function foodDeleteCancel() {
|
||||
foodForDeletion.value = null;
|
||||
}
|
||||
|
||||
function foodDeleteConfirm() {
|
||||
if (foodForDeletion.value !== null) {
|
||||
loadResource(
|
||||
api.deleteFood(foodForDeletion.value.id).then(res => {
|
||||
foodForDeletion.value = null;
|
||||
return getList();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
43
app/javascript/components/TheLog.vue
Normal file
43
app/javascript/components/TheLog.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<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="appConfig.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 setup>
|
||||
|
||||
import { computed, onBeforeMount, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import LogShow from "./LogShow";
|
||||
import api from "../lib/Api";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
import { useAppConfigStore } from "../stores/appConfig";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const route = useRoute();
|
||||
const appConfig = useAppConfigStore();
|
||||
|
||||
const log = ref(null);
|
||||
const logId = computed(() => route.params.id);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getLog(logId.value)
|
||||
.then(data => { log.value = data; return data; })
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
62
app/javascript/components/TheLogCreator.vue
Normal file
62
app/javascript/components/TheLogCreator.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<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 setup>
|
||||
|
||||
import { computed, onBeforeMount, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import LogEdit from "./LogEdit";
|
||||
import api from "../lib/Api";
|
||||
import * as Errors from '../lib/Errors';
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const validationErrors = ref({});
|
||||
const log = reactive({
|
||||
date: null,
|
||||
rating: null,
|
||||
notes: null,
|
||||
recipe: null
|
||||
});
|
||||
|
||||
const recipeId = computed(() => route.params.recipeId);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getRecipe(recipeId.value, null, null, null, data => log.recipe = data)
|
||||
);
|
||||
});
|
||||
|
||||
function save() {
|
||||
log.original_recipe_id = recipeId.value;
|
||||
validationErrors.value = {};
|
||||
|
||||
loadResource(
|
||||
api.postLog(log)
|
||||
.then(() => router.push('/'))
|
||||
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
56
app/javascript/components/TheLogEditor.vue
Normal file
56
app/javascript/components/TheLogEditor.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<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 setup>
|
||||
|
||||
import { computed, onBeforeMount, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import api from "../lib/Api";
|
||||
import * as Errors from "../lib/Errors";
|
||||
import LogEdit from "./LogEdit";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const validationErrors = ref({});
|
||||
const log = ref(null);
|
||||
|
||||
const logId = computed(() => route.params.id);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadResource(
|
||||
api.getLog(logId.value)
|
||||
.then(data => { log.value = data; return data; })
|
||||
);
|
||||
});
|
||||
|
||||
function save() {
|
||||
validationErrors.value = {};
|
||||
loadResource(
|
||||
api.patchLog(log.value)
|
||||
.then(() => router.push('/'))
|
||||
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
71
app/javascript/components/TheLogList.vue
Normal file
71
app/javascript/components/TheLogList.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="title">
|
||||
Log Entries
|
||||
</h1>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipe</th>
|
||||
<th>Date</th>
|
||||
<th>Rating</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<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 :model-value="l.rating" readonly></app-rating></td>
|
||||
<td>{{l.notes}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import api from "../lib/Api";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
|
||||
const logData = ref(null);
|
||||
const search = reactive({
|
||||
page: 1,
|
||||
per: 25
|
||||
});
|
||||
|
||||
const logs = computed(() => logData.value?.logs || []);
|
||||
const totalPages = computed(() => logData.value?.total_pages || 0);
|
||||
const currentPage = computed(() => logData.value?.current_page || 0);
|
||||
|
||||
const getList = debounce(function() {
|
||||
loadResource(
|
||||
api.getLogList(search.page, search.per)
|
||||
.then(data => logData.value = data)
|
||||
);
|
||||
}, 500, {leading: true, trailing: true});
|
||||
|
||||
watch(search,
|
||||
() => getList(),
|
||||
{
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
function changePage(idx) {
|
||||
search.page = idx;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
93
app/javascript/components/TheNotesList.vue
Normal file
93
app/javascript/components/TheNotesList.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Note</th>
|
||||
<th>Date</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<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>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { onBeforeMount, ref } from "vue";
|
||||
import api from "../lib/Api";
|
||||
import NoteEdit from "./NoteEdit";
|
||||
import { useLoadResource } from "../lib/useLoadResource";
|
||||
|
||||
const { loadResource } = useLoadResource();
|
||||
const notes = ref([]);
|
||||
const editNote = ref(null);
|
||||
|
||||
onBeforeMount(() => {
|
||||
refreshList();
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
loadResource(
|
||||
api.getNoteList()
|
||||
.then(data => notes.value = data)
|
||||
);
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
editNote.value = { id: null, content: "" };
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
loadResource(
|
||||
api.postNote(editNote.value)
|
||||
.then(() => {
|
||||
editNote.value = null;
|
||||
return refreshList();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function cancelNote() {
|
||||
editNote.value = null;
|
||||
}
|
||||
|
||||
function deleteNote(n) {
|
||||
loadResource(
|
||||
api.deleteNote(n)
|
||||
.then(() => {
|
||||
return refreshList();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user