Compare commits

...

204 Commits

Author SHA1 Message Date
c26d94e504 Fix styles and rec title
All checks were successful
parsley/pipeline/head This commit looks good
2024-12-31 17:43:40 -06:00
659d7405b2 Fix dockerfile
All checks were successful
parsley/pipeline/head This commit looks good
2024-10-18 14:55:09 -05:00
169dda4d3b Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 14:50:37 -05:00
f5e32cf888 Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 14:42:04 -05:00
1b6b3135c7 Fix dockerfile 2024-10-18 14:39:41 -05:00
808c805cde Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 10:18:16 -05:00
7ead02ad7e progress and autosize 2024-10-02 16:20:07 -05:00
67c23015ab Continue converting to composition api 2024-10-02 14:34:50 -05:00
0d35f50dbf Continue converting to composition api 2024-10-01 09:32:09 -05:00
a071e6b21e Begin converting to composition api 2024-09-29 13:35:49 -05:00
b957d44aed style cleanup 2024-09-29 09:44:40 -05:00
f246f71aa9 Upgrade shakapacker, vue; switch to pinia 2024-09-28 20:58:25 -05:00
bb2e29f25c upgrade ruby/rails 2024-09-28 11:50:07 -05:00
552524e78b upgrade to shakapacker 2022-12-01 20:13:53 -06:00
2b77bc9fb8 rails 7/ruby 3 2022-12-01 18:02:26 -06:00
855b4ac779 Updated ruby; bumped gems 2022-11-22 13:10:47 -06:00
b311a7d7e8 Fixed actioncable
Some checks failed
parsley/pipeline/head There was a failure building this commit
2022-02-02 21:25:11 -06:00
7f1cf99237 Fix nginx 2022-02-02 21:19:33 -06:00
dd915624a3 update dependencies; live task editing 2022-02-02 21:12:27 -06:00
a306f72215 Small fixes 2022-02-02 15:55:24 -06:00
1a695b795e Update gitignore 2021-12-31 13:15:01 -06:00
64735b5ee5 Convert recipe to task list 2021-05-09 23:31:44 -05:00
e15bd576e6 Add extremely long session 2021-05-09 19:04:40 -05:00
66f6b5346d fix food ndbn selection caching; upgrade gems and ruby 2021-05-09 17:38:08 -05:00
6dcbb80794 Added markdown help 2020-08-30 17:43:47 -05:00
3b5b95292c finished removing jbuilder 2020-08-11 11:05:19 -05:00
7dfa978c26 more conversion away form jbuilder 2020-08-07 12:33:06 -05:00
08df218a00 begin removing jbuilder 2020-08-06 21:23:31 -05:00
f18c5a021c Fix recipe page history; fix caching 2020-08-06 20:26:45 -05:00
694bf43738 add default port to dockerfile 2020-05-04 17:59:11 -05:00
99d9cd587e create pid dir on startup 2020-05-04 17:53:31 -05:00
b85c2e1f60 ruby and rails upgrade 2020-05-02 14:12:04 -05:00
cf1294cf20 Fixed compose 2020-04-12 13:23:22 -05:00
9cc3150241 Fixed compose 2020-04-12 13:21:25 -05:00
5a5db7d299 updated env 2020-04-12 13:11:00 -05:00
bb6bf1559f bumped versions 2020-01-18 18:07:07 -06:00
997da180ff Removed traefik labels 2019-12-20 20:31:42 -06:00
050e8c0a12 Updated compose 2019-11-10 10:40:55 -06:00
ccb87d7019 Updated compose; added smooth transitions 2019-11-10 10:40:26 -06:00
eeb8e84344 Added package upgrade to Dockerfile 2019-09-13 11:46:24 -05:00
474e417dca Fixed service worker 2019-09-13 10:50:28 -05:00
ad9850a8e2 bumped deps 2019-09-10 17:04:33 -05:00
b79134ef9a fixed compose 2019-08-05 19:01:45 -05:00
f4fb33c6c3 upgraded gems 2019-08-05 18:54:49 -05:00
6115afca50 Added beta environment 2019-06-02 14:35:15 -05:00
9bcfeae9ef added angled parsley logo svg source 2019-03-27 20:58:33 -05:00
ab6f7cf5ee Update icon 2019-03-24 09:57:52 -05:00
cf09c5f000 Added favicons 2019-03-23 14:55:27 -05:00
bea495cd8a Fixed specs 2019-03-23 13:54:21 -05:00
123c943637 version update 2019-03-23 13:45:39 -05:00
e966a778c0 changed Dockerfile 2019-02-02 15:39:09 -06:00
d90f4f81bf enabled airbrake 2019-02-02 15:32:42 -06:00
521c62d09b add serving logic 2018-09-22 01:56:39 -05:00
c4ef70d0e8 fix ndbn 2018-09-15 08:50:03 -05:00
337d65c298 fix pg col 2018-09-14 19:46:02 -05:00
2e86208476 Added branded food DB 2018-09-14 19:32:49 -05:00
532c9372ea updates 2018-09-13 14:51:41 -05:00
0c4c5b899b updates 2018-09-12 17:17:15 -05:00
ffed63e0b3 fixed
Some checks failed
parsley/pipeline/head There was a failure building this commit
2018-09-12 14:17:18 -05:00
128bfcd535 bumped versions 2018-09-12 11:15:04 -05:00
b802542869 w 2018-09-12 09:43:50 -05:00
2fd83a5d3d front end work 2018-09-11 22:56:26 -05:00
47014118c8 work 2018-09-11 17:13:22 -05:00
56fe5aae35 recipe as ingredient work 2018-09-11 10:38:07 -05:00
a6cf7c1b16 icons 2018-09-10 17:25:36 -05:00
eb34393407 merged from master 2018-09-10 10:24:56 -05:00
d8720a8a6c fixed icon render 2018-09-09 21:01:17 -05:00
db02888776 icons 2018-09-09 16:37:25 -05:00
b1e5c22101 task lists 2018-09-07 21:56:13 -05:00
18603dc783 tasks
Some checks failed
parsley/pipeline/head There was a failure building this commit
2018-09-06 18:16:13 -05:00
4b25f753f1 tasks 2018-09-05 17:49:21 -05:00
41117e2e7f icon 2018-09-05 11:00:35 -05:00
813e54d8c4 icons 2018-08-29 16:58:07 -05:00
6aa2c8ee4a ui 2018-08-28 16:52:56 -05:00
9f0422acf8 start frontend 2018-08-28 10:39:11 -05:00
8992a4a082 backend 2018-08-27 17:46:33 -05:00
021c066cf4 rai 2018-08-27 16:44:45 -05:00
a2f2a05679 conditional deploy 2018-07-22 17:14:34 -05:00
f97c40bbeb jenkins 2018-07-22 17:09:11 -05:00
0d37ce2ab9 jenkins 2018-07-22 16:42:43 -05:00
10f50cb920 jenkins 2018-07-22 16:39:08 -05:00
b7a13019ae jenkins 2018-07-22 16:28:25 -05:00
f79a94fbcb jenkins 2018-07-22 16:25:55 -05:00
e3f57ac9cd jenkins 2018-07-22 16:15:53 -05:00
8f93591395 Fixed compose 2018-07-22 14:51:02 -05:00
8eeed33828 UI updates 2018-07-15 17:00:25 -05:00
bc681481ab Fixed source and tag editing 2018-07-13 16:01:56 -05:00
7071a7203b Merge branch 'master' of ssh://source.elbert.us:777/dan/parsley 2018-07-13 15:42:15 -05:00
cd802f0d97 adding temp logo and fixing specs 2018-07-13 15:42:08 -05:00
b9c9d0d145 Updated compose 2018-07-10 10:31:43 -05:00
7ee316f2b4 Merge branch 'master' into production 2018-06-09 13:42:46 -05:00
6ca63c2a99 Updated prod compose 2018-06-09 13:42:38 -05:00
4c9ef33f20 Merge branch 'master' into production 2018-06-09 13:16:11 -05:00
306e6a85db removed old view files 2018-06-09 13:15:07 -05:00
71a2ee4673 version bump 2018-06-09 13:05:50 -05:00
46e8c9376f UI fixes 2018-06-09 12:36:46 -05:00
cd6f408d39 ui 2018-05-09 15:17:07 -05:00
3c87ee8083 UI updates; added delete 2018-05-01 10:55:57 -05:00
c3dbefbb4f caching 2018-04-18 17:04:25 -05:00
91e9e78650 service worker 2018-04-18 14:00:38 -05:00
fcf191e355 sw work 2018-04-17 18:38:48 -05:00
26b4401450 recipe 2018-04-17 09:59:38 -05:00
0957d84ca0 show recipe 2018-04-16 11:28:58 -05:00
f58039e3c6 recipe work 2018-04-15 15:19:50 -05:00
6f213865f0 logs 2018-04-15 14:15:42 -05:00
248700778f show log 2018-04-14 15:04:08 -05:00
642a9b362c log editing 2018-04-13 23:32:34 -05:00
46072422d4 logs 2018-04-13 10:25:18 -05:00
2da16e334e notes work 2018-04-07 10:54:56 -05:00
63a566d697 colors, style, tag editor 2018-04-04 19:46:02 -05:00
d81818f2d4 small refactor 2018-04-03 20:26:31 -05:00
c13b3a09bd Tag editor; ingredient editor 2018-04-03 18:31:20 -05:00
a07f037b8f ingredient editing 2018-04-03 10:29:57 -05:00
1154da1fd1 caching 2018-04-02 11:21:35 -05:00
d587ed58b7 ingredients 2018-04-02 00:10:06 -05:00
4c1e0929f1 year 2018-04-01 22:33:14 -05:00
3b1a9246fd recipe editing 2018-04-01 22:32:13 -05:00
1c4fa37778 recipe editing 2018-04-01 21:43:23 -05:00
5579876c63 login 2018-04-01 12:17:54 -05:00
97eca6d319 basic login 2018-03-30 17:08:09 -05:00
bb223af9ae vue work 2018-03-30 14:31:09 -05:00
98a204ab59 gemfile cleanup; added js/lib 2018-03-29 22:08:13 -05:00
56d5965dd2 rails 5.2 2018-03-29 02:15:47 -05:00
1c4fbe15de start vue conversion 2018-03-29 01:57:00 -05:00
dea393d514 Merge branch 'master' of ssh://source.elbert.us:777/dan/parsley 2018-03-29 00:28:41 -05:00
ebb823f66e Merge branch 'master' into production 2018-03-21 13:35:40 -05:00
dc017aa2f1 Added tzinfo gem 2018-03-21 13:35:18 -05:00
69c02fae9e Merge branch 'master' into production 2018-03-21 13:06:21 -05:00
cf97bda9e0 bumped versions 2018-03-21 13:06:12 -05:00
c081ae351a Merge branch 'master' into production 2017-05-08 11:20:11 -05:00
5b7061e464 recipe index 2017-05-08 11:20:04 -05:00
69ccbf58a2 Merge branch 'master' into production 2017-04-24 11:57:43 -05:00
e3f480b476 recipe list cleanup 2017-04-24 11:57:34 -05:00
ebf10e36b3 update step migration 2017-04-14 18:40:43 -05:00
fcb827cc77 nutrition data cleanup 2017-04-14 17:06:43 -05:00
2a5301f5d5 finished markdown transition 2017-04-14 16:40:38 -05:00
f560764292 markdown editor 2017-04-14 09:01:51 -05:00
624ca9ee7a Added markdown 2017-04-13 16:18:20 -05:00
a11ab12cd8 gem update; cleanup 2017-04-13 12:08:29 -05:00
afc2793375 Added recipe search; bumped gems and ruby version 2017-03-29 16:52:59 -05:00
977e0e8d6a bit of UI cleanup 2017-02-21 12:57:56 -06:00
2ea20048d4 Added spec 2016-10-21 10:54:17 -05:00
64b8e03ea6 Updated seed file 2016-10-21 09:41:59 -05:00
b09b3eb196 Added tags to homepage 2016-10-20 18:06:53 -05:00
cc398bd9d8 Added tag editing 2016-10-20 15:48:33 -05:00
45b0a9c7b1 added tags schema and model 2016-10-14 12:47:24 -05:00
e758af25b2 added notes 2016-10-14 12:19:00 -05:00
f7ab63e0d3 Fied paging 2016-09-28 17:28:40 -05:00
92b962e6b9 Fixed nil rating bug 2016-09-28 17:18:17 -05:00
0990551cfc added new recicpe button to top of rec index 2016-09-28 17:09:52 -05:00
36cbf92df0 Added sorting columns to recipe index 2016-09-28 17:08:43 -05:00
779cdb6173 increased usda search max results 2016-09-28 14:12:09 -05:00
087504e539 Changed production log level to info 2016-09-22 13:47:03 -05:00
2147c1e48c bumped gem versions 2016-09-17 12:28:02 -05:00
51a3be23e9 log work 2016-08-16 12:41:31 -05:00
e6a9e00f82 logs 2016-08-15 17:43:02 -05:00
72fffcfbca log work 2016-08-12 16:05:24 -05:00
d18d3a14b0 Fix ingredient NDBN selection 2016-07-29 11:06:10 -05:00
78932eb7ee updated base image 2016-07-28 12:02:41 -05:00
203ef90cf0 Rails 5 changed db migrate 2016-07-27 22:33:22 -05:00
9c569693b1 log work 2016-07-27 22:30:57 -05:00
cfa7d95afe Updates to support ruby 2.3, docker-compose v2, and docker development 2016-07-08 12:56:40 -05:00
b8e0835741 docker conversion to postgresql 2016-07-08 11:17:42 -05:00
4137c9fd7c Initial rails 5 conversion 2016-07-07 17:47:47 -05:00
37f150e1cc start work on logs 2016-07-06 21:00:35 -05:00
e42150f883 Fixed docker config: 2016-07-06 16:47:00 -05:00
8443220a79 Fixed db config 2016-07-06 16:36:04 -05:00
df35dfcf5e Move production to postgres 2016-07-06 16:25:17 -05:00
846c8aa294 Updated recipe decorator 2016-07-06 16:23:06 -05:00
c71e356195 Fixed yield parser; fixed N+1 query issue 2016-07-05 16:48:59 -05:00
16bc8b562b Added ingredient functionality 2016-07-05 16:31:36 -05:00
2ad4a22184 Updated ingredient editor 2016-07-05 13:07:20 -05:00
30726ec1b8 update ingredient list 2016-07-05 11:19:18 -05:00
c0d43171a4 fixed ingredient bug; added button 2016-06-29 19:14:17 -05:00
8811109351 enhanced nutrition data 2016-06-22 13:49:03 -05:00
72ac0aab3b Changed yields input to text_field 2016-05-10 14:39:26 -05:00
80c203b227 small ui changes 2016-04-04 14:28:23 -05:00
d368ef4a6f Better yield use 2016-04-03 18:03:51 -05:00
9994560807 Fixed small html error 2016-03-23 16:42:15 -05:00
5f20e04b79 Fixed bad column definition 2016-03-10 18:03:20 -06:00
238adc4b61 USDA density calculations now more powerful; added ability to parse cubic values 2016-03-10 16:06:39 -06:00
a9527c9626 Fixed usda parser; start updating density calculaitons 2016-03-09 20:12:38 -06:00
2f752eae61 Adding USDA weights; usda_importer refactor 2016-03-09 18:53:47 -06:00
068b01a7c8 Gem version bump 2016-03-09 11:51:27 -06:00
e6182bd6ff Removed .DS_store 2016-03-03 17:59:03 -06:00
Dan Elbert
7b8d065f20 Bug when user is anon 2016-03-03 13:30:34 -06:00
Dan Elbert
78dfa9120e Added admin; updated calculator; added owner to ingredients 2016-03-03 13:12:42 -06:00
b890665966 Added specs 2016-02-27 20:23:14 -06:00
dd493a09d5 Extra conversion options 2016-02-27 20:12:41 -06:00
a86c5afae2 display nutrition data in recipe view 2016-02-14 19:29:34 -06:00
48d296a7c9 removed unused specs 2016-02-02 16:04:21 -06:00
d5082f9c16 Major unit conversion refactor + lots more specs 2016-02-02 15:48:20 -06:00
ffb2c92f74 calculator 2016-01-31 00:00:32 -06:00
9cc9fb2c2f small display fixes 2016-01-30 21:51:32 -06:00
edfd6f12d6 more bulk editor work 2016-01-30 21:35:39 -06:00
3ca786281d Added about page; fixed bulk editing 2016-01-30 21:20:15 -06:00
422c77b131 Added ingredient prep to recipe_ingredient; update to bulk editing 2016-01-30 20:29:35 -06:00
0cc568344e UI updates 2016-01-30 17:02:19 -06:00
205a29ce77 Updated style; ingredient editor work 2016-01-30 15:49:17 -06:00
Dan Elbert
ff231ceee0 usda linking 2016-01-29 18:45:20 -06:00
Dan Elbert
2dca779294 Added long descirption to usda foods 2016-01-28 18:18:45 -06:00
Dan Elbert
70e7a8b415 Ingredient editor 2016-01-28 14:19:51 -06:00
7f7a81d49a ingredient linking 2016-01-24 19:06:26 -06:00
9f04a65e19 Added usda food data 2016-01-24 17:10:43 -06:00
682 changed files with 3994658 additions and 14639 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

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

1
.foreman Normal file
View File

@ -0,0 +1 @@
port: 3000

19
.gitignore vendored
View File

@ -9,7 +9,7 @@
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal
/db/*.sqlite3*
# Ignore all logfiles and tempfiles.
/log/*
@ -20,3 +20,20 @@
.byebug_history
/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.*

View File

@ -1 +1 @@
2.3.0
3.3.5

View File

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

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

View File

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

View File

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

@ -0,0 +1,2 @@
rails: bundle exec rails s -b 0.0.0.0
shakapacker: bin/shakapacker-dev-server

View File

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

Binary file not shown.

BIN
app/assets/.DS_Store vendored

Binary file not shown.

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

View File

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

View File

@ -1,46 +0,0 @@
var flashMessageTypeMap = {
error: "danger",
notice: "success"
};
function flashMessage(flashType, message) {
var timeoutIdContainer = {};
if (flashMessageTypeMap[flashType]) {
flashType = flashMessageTypeMap[flashType];
}
var closeButton = $("<button type='button' />")
.addClass("close")
.append($("<span />").html("&times;"))
.bind("click.Flash", function() { $(this).parent().hide({effect: "fade", duration: 1000}); clearTimeout(timeoutIdContainer.id); });
var $flashDiv = $("<div></div>")
.html(message)
.append(closeButton)
.addClass("popup")
.addClass("alert")
.addClass("alert-" + flashType)
.hide()
.appendTo("#flashContainer")
.show({effect: "pulsate", times: 1, duration: 1500});
timeoutIdContainer.id = setTimeout(function() {
$flashDiv.unbind(".Flash");
$flashDiv.hide({effect: "fade", duration: 1000});
}, 5000);
}
$(document).on("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);
});
});

View File

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

View File

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

View File

@ -1,44 +0,0 @@
#flashHolder {
display: none;
}
#flashContainer {
position: fixed;
width: 100%;
z-index: 9999;
}
.alert.popup {
position: fixed;
top: 15px;
left: 50%;
width: 30em;
margin-left: -15em;
}
.alert {
}
.flash {
text-align: center;
width: 30em;
padding: 5px;
margin-left: auto;
margin-right: auto;
}
.error {
background-color: #fff6ff;
border: 3px solid #fda8a8;
}
.warning {
background-color: #ffffdd;
border: 3px solid #ffdd00;
}
.notice {
background-color: #D8F6CE;
border: 3px solid #3ADF00;
}

View File

@ -1,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');
}

View File

@ -1,3 +0,0 @@
// Place all the styles related to the ingredients controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View File

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

View File

@ -1,64 +0,0 @@
span.twitter-typeahead .tt-menu,
span.twitter-typeahead .tt-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
text-align: left;
background-color: #ffffff;
border: 1px solid #cccccc;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
background-clip: padding-box;
}
span.twitter-typeahead .tt-hint {
color: #A5A5A5;
}
span.twitter-typeahead .tt-suggestion {
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.42857143;
color: #333333;
white-space: nowrap;
}
span.twitter-typeahead .tt-suggestion.tt-cursor,
span.twitter-typeahead .tt-suggestion:hover,
span.twitter-typeahead .tt-suggestion:focus {
color: #ffffff;
text-decoration: none;
outline: 0;
background-color: #337ab7;
}
span.twitter-typeahead {
width: 100%;
}
.input-group span.twitter-typeahead {
display: block !important;
}
.input-group span.twitter-typeahead .tt-menu,
.input-group span.twitter-typeahead .tt-dropdown-menu {
top: 32px !important;
}
.input-group.input-group-sm span.twitter-typeahead .tt-menu,
.input-group.input-group-sm span.twitter-typeahead .tt-dropdown-menu {
top: 28px !important;
}
.input-group.input-group-lg span.twitter-typeahead .tt-menu,
.input-group.input-group-lg span.twitter-typeahead .tt-dropdown-menu {
top: 44px !important;
}

View File

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View 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

View 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

View 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

View File

@ -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
end
def current_user

View 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

View 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

View File

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

View File

@ -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
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)
render json: json
end
end

View 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

View 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

View File

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

View 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

View 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

View 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

View File

@ -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
respond_to do |format|
if user = User.authenticate(params[:username], params[:password])
set_current_user(user)
flash[:notice] = "Welcome, #{user.display_name}"
redirect_to root_path
format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
format.json { render json: { success: true, user: UserSerializer.for(current_user).serialize } }
else
flash[:error] = "Invalid credentials"
render :login
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)
respond_to do |format|
if @user.save
set_current_user(@user)
redirect_to root_path, notice: 'User was successfully created.'
format.html { redirect_to root_path, notice: 'User created.' }
format.json { render json: UserSerializer.for(@user), status: :created, location: @user }
else
render action: :new
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
respond_to do |format|
if @user.update(user_params)
redirect_to root_path, notice: 'User account updated'
format.html { redirect_to root_path, notice: 'User updated.' }
format.json { render json: UserSerializer.for(@user) , status: :created, location: @user }
else
render action: 'edit'
format.html { render :edit }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

@ -0,0 +1,4 @@
module FoodsHelper
end

View File

@ -1,2 +0,0 @@
module IngredientsHelper
end

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,11 @@
<template>
<div>
<h1>404!</h1>
<p>WTF?</p>
</div>
</template>
<script setup>
</script>

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

View File

@ -0,0 +1,12 @@
<template>
</template>
<script setup>
</script>
<style lang="scss" scoped>
</style>

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

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

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

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

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

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

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

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

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

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

View 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