Compare commits

..

21 Commits
vue3ts ... main

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
193 changed files with 10879 additions and 15206 deletions

View File

@ -4,4 +4,6 @@ db/*.sqlite*
tmp/*.*
public/assets
public/packs
node_modules/
node_modules/
.yarn
.pnp.*

View File

@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

13
.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,7 +20,6 @@
.byebug_history
/public/assets
/dist
.DS_Store
/public/packs
@ -28,3 +27,13 @@
/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 @@
3.0.3
3.3.5

View File

@ -1,17 +1,14 @@
FROM ruby:3.0.3-bullseye
FROM ruby:3.3.5-bookworm
RUN curl -sL https://deb.nodesource.com/setup_lts.x | bash - && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
apt-get update && apt-get dist-upgrade -y && \
apt-get install -y \
nodejs \
yarn \
nginx && \
yarn global add @vue/cli && \
gem update --system && gem install bundler && \
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*
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
@ -31,12 +28,12 @@ COPY Gemfile* ./
RUN bundle install
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN yarn install --immutable
COPY . .
# Compile assets
RUN yarn build
RUN env RAILS_ENV=production bundle exec rails shakapacker:clobber shakapacker:compile
ENV PORT=80
EXPOSE 80

32
Gemfile
View File

@ -1,28 +1,30 @@
source 'https://rubygems.org'
gem 'rails', '6.1.4.1'
gem 'pg', '~> 1.2.3'
gem 'rails', '7.2.1'
gem 'pg', '~> 1.5.8'
gem 'shakapacker', '8.0.2'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'oj', '~> 3.13.9'
gem 'oj', '~> 3.16.6'
gem 'csv', '~> 3.3'
gem 'kaminari', '~> 1.2.1'
gem 'unitwise', '~> 2.2.0'
gem 'redcarpet', '~> 3.5.1'
gem 'kaminari', '~> 1.2.2'
gem 'unitwise', '~> 2.3.0'
gem 'redcarpet', '~> 3.6.0'
gem 'dalli', '~> 3.0.4'
gem 'puma', '~> 5.5'
gem 'bcrypt', '~> 3.1.16'
gem 'dalli', '~> 3.2.8'
gem 'puma', '~> 6.4'
gem 'bcrypt', '~> 3.1.18'
gem 'tzinfo-data'
group :development, :test do
gem 'sqlite3', '~> 1.4.2'
gem 'sqlite3', '~> 2.1.0'
gem 'listen', '~> 3.3'
gem 'rspec-rails', '~> 5.0.2'
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.2.0'
gem 'database_cleaner', '~> 2.0.1'
gem 'factory_bot_rails', '~> 6.4.3'
gem 'database_cleaner', '~> 2.0.2'
end

View File

@ -1,230 +1,322 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (>= 2.7.1)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.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)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
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)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
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 (6.1.4.1)
activesupport (= 6.1.4.1)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
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)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
bcrypt (3.1.16)
bootsnap (1.9.3)
msgpack (~> 1.0)
builder (3.2.4)
concurrent-ruby (1.1.9)
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)
dalli (3.0.4)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.1)
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)
diff-lcs (1.4.4)
erubi (1.10.0)
factory_bot (6.2.0)
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.2.0)
factory_bot (~> 6.2.0)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
ffi (1.15.4)
globalid (1.0.0)
activesupport (>= 5.0)
i18n (1.8.11)
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)
kaminari (1.2.1)
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)
kaminari-actionview (= 1.2.1)
kaminari-activerecord (= 1.2.1)
kaminari-core (= 1.2.1)
kaminari-actionview (1.2.1)
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.1)
kaminari-activerecord (1.2.1)
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
liner (0.2.4)
listen (3.7.0)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.12.0)
logger (1.6.1)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
nokogiri (>= 1.12.0)
lumberjack (1.2.10)
mail (2.8.1)
mini_mime (>= 0.1.1)
marcel (1.0.2)
net-imap
net-pop
net-smtp
marcel (1.0.4)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (1.0.0)
mini_mime (1.1.2)
mini_portile2 (2.6.1)
minitest (5.14.4)
msgpack (1.4.2)
nio4r (2.5.8)
nokogiri (1.12.5)
mini_portile2 (~> 2.6.1)
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)
oj (3.13.9)
parslet (1.8.2)
pg (1.2.3)
puma (5.5.2)
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.6.0)
rack (2.2.3)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
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 (= 6.1.4.1)
sprockets-rails (>= 2.0.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.0.3)
activesupport (>= 4.2.0)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
method_source
rake (>= 0.13)
thor (~> 1.0)
rake (13.0.6)
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
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)
redcarpet (3.5.1)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
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.10.0)
rspec-mocks (3.10.2)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-rails (5.0.2)
actionpack (>= 5.2)
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)
rspec-core (~> 3.10)
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.3)
semantic_range (>= 2.3.0)
shellany (0.0.1)
signed_multiset (0.2.1)
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
thor (1.1.0)
sqlite3 (2.1.0)
mini_portile2 (~> 2.8.0)
stringio (3.1.1)
thor (1.3.2)
thread_safe (0.3.6)
tzinfo (2.0.4)
timeout (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.5)
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
unitwise (2.2.0)
unitwise (2.3.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 1.5)
parslet (~> 2.0)
signed_multiset (~> 0.2)
websocket-driver (0.7.5)
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.5.1)
zeitwerk (2.6.18)
PLATFORMS
ruby
DEPENDENCIES
bcrypt (~> 3.1.16)
bcrypt (~> 3.1.18)
bootsnap (>= 1.1.0)
dalli (~> 3.0.4)
database_cleaner (~> 2.0.1)
factory_bot_rails (~> 6.2.0)
kaminari (~> 1.2.1)
listen (~> 3.3)
oj (~> 3.13.9)
pg (~> 1.2.3)
puma (~> 5.5)
rails (= 6.1.4.1)
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.5.1)
rspec-rails (~> 5.0.2)
sqlite3 (~> 1.4.2)
redcarpet (~> 3.6.0)
rspec-rails (~> 7.0.1)
shakapacker (= 8.0.2)
sqlite3 (~> 2.1.0)
tzinfo-data
unitwise (~> 2.2.0)
unitwise (~> 2.3.0)
BUNDLED WITH
2.2.32
2.5.20

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2020 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

View File

@ -1,2 +1,2 @@
frontend: yarn serve
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) 2021 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

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

@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
def verified_request?
if request.content_type == "application/json"
if request.media_type == "application/json"
true
else
super()
@ -42,8 +42,18 @@ class ApplicationController < ActionController::Base
if owner
yield if block_given?
else
flash[:warning] = "Operation Not Permitted"
redirect_to root_path
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

View File

@ -34,8 +34,10 @@ class CalculatorController < ApplicationController
begin
input_unit = UnitConversion.parse(input)
input_unit.unitwise
rescue UnitConversion::UnparseableUnitError => e
data[:errors][:input] << 'invalid string'
data[:errors][:input] << e.message
input_unit = nil
end
if !input_unit.nil?

View File

@ -1,5 +1,5 @@
class NotesController < ApplicationController
before_action :set_note, only: [:show, :edit, :update, :destroy]
before_action :set_note, only: [:show, :update, :destroy]
before_action :ensure_valid_user
# GET /notes

View File

@ -9,6 +9,7 @@ class TaskItemsController < ApplicationController
@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
@ -17,6 +18,7 @@ class TaskItemsController < ApplicationController
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
@ -30,6 +32,8 @@ class TaskItemsController < ApplicationController
@task_items.each { |i| i.destroy }
end
TaskChannel.update_task_list(@task_list)
head :no_content
end
@ -41,6 +45,8 @@ class TaskItemsController < ApplicationController
@task_items.each { |i| i.update_attribute(:completed, new_status) }
end
TaskChannel.update_task_list(@task_list)
head :no_content
end
@ -59,4 +65,4 @@ class TaskItemsController < ApplicationController
@task_item = @task_list.task_items.find(params[:id])
end
end
end

View File

@ -27,6 +27,7 @@ class TaskListsController < ApplicationController
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
@ -40,13 +41,14 @@ class TaskListsController < ApplicationController
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
@ -61,4 +63,4 @@ class TaskListsController < ApplicationController
@task_list = TaskList.find(params[:id])
end
end
end

View File

@ -50,7 +50,7 @@ class UsersController < ApplicationController
if @user.save
set_current_user(@user)
format.html { redirect_to root_path, notice: 'User created.' }
format.json { render :show, status: :created, location: @user }
format.json { render json: UserSerializer.for(@user), status: :created, location: @user }
else
format.html { render :new }
format.json { render json: @user.errors, status: :unprocessable_entity }
@ -68,7 +68,7 @@ class UsersController < ApplicationController
respond_to do |format|
if @user.update(user_params)
format.html { redirect_to root_path, notice: 'User updated.' }
format.json { render :show, status: :created, location: @user }
format.json { render json: UserSerializer.for(@user) , status: :created, location: @user }
else
format.html { render :edit }
format.json { render json: @user.errors, status: :unprocessable_entity }

View File

@ -1,83 +1,67 @@
<template>
<div id="app">
<vue-progress-bar></vue-progress-bar>
<app-progress-bar></app-progress-bar>
<app-navbar></app-navbar>
<section id="main" class="">
<div class="container">
<transition name="fade" mode="out-in">
<router-view v-if="!hasError"></router-view>
<div v-else>
<h1>Error!</h1>
<p>{{error}}</p>
</div>
</transition>
<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>
<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";
import { mapMutations, mapState } from "vuex";
import api from "../lib/Api";
import TWEEN from '@tweenjs/tween.js';
const globalTweenGroup = useGlobalTweenGroup();
let animationLoop = true;
export default {
data() {
return {
api: api
};
},
computed: {
...mapState({
hasError: state => state.error !== null,
error: state => state.error,
authChecked: state => state.authChecked,
initialLoad: state => state.initialLoad
})
},
const appConfig = useAppConfigStore();
const hasError = computed(() => appConfig.error !== null);
watch: {
isLoading(val) {
const { loadResource } = useLoadResource();
const { checkAuthentication } = useCheckAuthentication(loadResource);
watch(
() => appConfig.initialLoad,
(val) => {
if (val) {
this.$Progress.start();
} else {
this.$Progress.finish();
nextTick(() => document.body.classList.remove("loading"));
}
},
{ immediate: true }
);
initialLoad(val) {
if (val) {
this.$nextTick(() => {
document.body.classList.remove("loading");
});
}
}
},
created() {
// Setup global animation loop
function animate () {
TWEEN.update();
onMounted(() => {
// Setup global animation loop
function animate() {
if (animationLoop) {
globalTweenGroup.update();
requestAnimationFrame(animate);
}
animate();
if (this.user === null && this.authChecked === false) {
this.checkAuthentication();
}
},
mounted() {
if (this.initialLoad) {
this.$nextTick(() => {
document.body.classList.remove("loading");
});
}
},
components: {
}
}
animate();
if (appConfig.user === null && appConfig.authChecked === false) {
checkAuthentication();
}
});
onUnmounted(() => {
animationLoop = false;
});
</script>

View File

@ -27,206 +27,190 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from "vue";
import debounce from 'lodash/debounce';
export default {
props: {
value: String,
id: String,
placeholder: String,
name: String,
inputClass: {
type: [String, Object, Array],
required: false,
default: null
},
minLength: {
type: Number,
default: 0
},
debounce: {
type: Number,
required: false,
default: 250
},
const emit = defineEmits(["update:modelValue", "inputClick", "optionSelected"]);
valueAttribute: String,
labelAttribute: String,
keyAttribute: String,
onGetOptions: Function,
searchOptions: Array
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
},
data() {
return {
options: [],
rawValue: "",
isListOpen: false,
activeListIndex: 0
}
},
valueAttribute: String,
labelAttribute: String,
keyAttribute: String,
created() {
this.rawValue = this.value;
onGetOptions: Function,
searchOptions: Array
});
},
const options = ref([]);
const rawValue = ref("");
const isListOpen = ref(false);
const activeListIndex = ref(0);
watch: {
value(newValue) {
this.rawValue = newValue;
}
},
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;
}
});
computed: {
finalInputClass() {
let cls = ['input'];
if (this.inputClass === null) {
return cls;
} else if (Array.isArray(this.inputClass)) {
return cls.concat(this.inputClass);
} else {
cls.push(this.inputClass);
return cls;
}
},
watch(
() => props.modelValue,
(newValue) => { rawValue.value = newValue; },
{ immediate: true }
);
debouncedUpdateOptions() {
return debounce(this.updateOptions, this.debounce);
}
},
function optionClass(idx) {
return activeListIndex.value === idx ? 'option active' : 'option';
}
methods: {
optionClass(idx) {
return this.activeListIndex === idx ? 'option active' : 'option';
},
function optionClick(opt) {
selectOption(opt);
}
optionClick(opt) {
this.selectOption(opt);
},
function optionKey(opt) {
if (props.keyAttribute) {
return opt[props.keyAttribute]
} else if (props.valueAttribute) {
return opt[props.valueAttribute];
} else {
return opt.toString();
}
}
optionKey(opt) {
if (this.keyAttribute) {
return opt[this.keyAttribute]
} else if (this.valueAttribute) {
return opt[this.valueAttribute];
} else {
return opt.toString();
}
},
function optionValue(opt) {
if (props.valueAttribute) {
return opt[props.valueAttribute];
} else {
return opt.toString();
}
}
optionValue(opt) {
if (this.valueAttribute) {
return opt[this.valueAttribute];
} else {
return opt.toString();
}
},
function optionLabel(opt) {
if (props.labelAttribute) {
return opt[props.labelAttribute];
} else {
return null;
}
}
optionLabel(opt) {
if (this.labelAttribute) {
return opt[this.labelAttribute];
} else {
return null;
}
},
function optionMousemove(idx) {
activeListIndex.value = idx;
}
optionMousemove(idx) {
this.activeListIndex = idx;
},
function clickHandler(evt) {
emit('inputClick', evt);
}
clickHandler(evt) {
this.$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);
}
blurHandler(evt) {
// blur fires before click. If the blur was fired because the user clicked a list item, immediately hiding the list here
// would prevent the click event from firing
setTimeout(() => {
this.isListOpen = false;
},250);
},
function inputHandler(evt) {
const newValue = evt.target.value;
inputHandler(evt) {
const newValue = evt.target.value;
if (rawValue.value !== newValue) {
if (this.rawValue !== newValue) {
rawValue.value = newValue;
this.rawValue = newValue;
emit("update:modelValue", newValue);
this.$emit("input", newValue);
if (newValue.length >= Math.max(1, this.minLength)) {
this.debouncedUpdateOptions(newValue);
} else {
this.isListOpen = false;
}
}
},
keydownHandler(evt) {
if (this.isListOpen === false)
return;
switch (evt.key) {
case "ArrowUp":
evt.preventDefault();
this.activeListIndex = Math.max(0, this.activeListIndex - 1);
break;
case "ArrowDown":
evt.preventDefault();
this.activeListIndex = Math.min(this.options.length - 1, this.activeListIndex + 1);
break;
case "Enter":
evt.preventDefault();
this.selectOption(this.options[this.activeListIndex]);
break;
case "Escape":
evt.preventDefault();
this.isListOpen = false;
break;
}
},
selectOption(opt) {
this.rawValue = this.optionValue(opt);
this.$emit("input", this.rawValue);
this.$emit("optionSelected", opt);
this.isListOpen = false;
},
updateOptions(value) {
let p = null;
if (this.searchOptions) {
const reg = new RegExp("^" + value, "i");
const matcher = o => reg.test(this.optionValue(o));
p = Promise.resolve(this.searchOptions.filter(matcher));
} else {
p = this.onGetOptions(value)
}
p.then(opts => {
this.options = opts;
this.isListOpen = opts.length > 0;
this.activeListIndex = 0;
})
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>
@import "../styles/variables";
@use "bulma/sass/utilities" as bulma;
$labelLineHeight: 0.8rem;
input.input {
&::placeholder {
color: $grey-darker;
color: bulma.$grey-darker;
}
}
@ -251,7 +235,7 @@
&.active {
color: white;
background-color: $turquoise;
background-color: bulma.$turquoise;
}
.opt_value {

View File

@ -1,47 +1,45 @@
<template>
<app-modal :open="open" :title="message" @dismiss="runCancel">
<div class="buttons">
<button type="button" class="button is-primary" @click="runConfirm">OK</button>
<button type="button" class="button" @click="runCancel">Cancel</button>
</div>
<app-modal :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>
<script setup>
export default {
props: {
cancel: {
type: Function,
required: true
},
const emit = defineEmits(["cancel", "confirm"]);
confirm: {
type: Function,
required: true
},
const props = defineProps({
message: {
type: String,
required: false,
default: 'Are you sure?'
},
message: {
type: String,
required: false,
default: 'Are you sure?'
},
title: {
type: String,
required: false,
default: "Confirm"
},
open: {
type: Boolean,
required: true
}
},
methods: {
runConfirm() {
this.confirm();
},
runCancel() {
this.cancel();
}
}
open: {
type: Boolean,
required: true
}
});
function runConfirm() {
emit("confirm");
}
function runCancel() {
emit("cancel");
}
</script>

View File

@ -1,38 +1,34 @@
<template>
<app-text-field :value="stringValue" @input="input" :label="label" type="date"></app-text-field>
<app-text-field :value="stringValue" @update:modelValue="input" :label="label" type="date"></app-text-field>
</template>
<script>
<script setup>
import { computed } from "vue";
import DateTimeUtils from "../lib/DateTimeUtils";
export default {
props: {
value: {
required: false,
type: [Date, String]
},
label: {
required: false,
type: String,
default: null
}
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
required: false,
type: [Date, String]
},
computed: {
stringValue() {
const d = DateTimeUtils.toDate(this.value);
return DateTimeUtils.formatDateForEdit(d);
}
},
methods: {
input(val) {
let d = DateTimeUtils.toDate(val + " 00:00");
this.$emit("input", d);
}
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>

View File

@ -5,57 +5,49 @@
</span>
</template>
<script>
<script setup>
import { computed } from "vue";
import DateTimeUtils from "../lib/DateTimeUtils";
export default {
props: {
dateTime: {
required: true,
type: [Date, String]
},
showDate: {
required: false,
type: Boolean,
default: true
},
showTime: {
required: false,
type: Boolean,
default: true
},
useInput: {
required: false,
type: Boolean,
default: false
}
const props = defineProps({
dateTime: {
required: true,
type: [Date, String]
},
computed: {
dateObj() {
return DateTimeUtils.toDate(this.dateTime);
},
showDate: {
required: false,
type: Boolean,
default: true
},
friendlyString() {
const parts = [];
if (this.showDate) {
parts.push(DateTimeUtils.formatDate(this.dateObj));
}
if (this.showTime) {
parts.push(DateTimeUtils.formatTime(this.dateObj, true));
}
return parts.join(" ");
},
showTime: {
required: false,
type: Boolean,
default: true
},
fullString() {
return DateTimeUtils.formatTimestamp(this.dateObj);
}
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>

View File

@ -1,5 +1,5 @@
<template>
<div class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}">
<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">
@ -19,67 +19,63 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
open: {
required: false,
type: Boolean,
default: false
},
import { useTemplateRef } from "vue";
import { onClickOutside } from '@vueuse/core'
hover: {
required: false,
type: Boolean,
default: false
},
const emit = defineEmits(["close", "open"]);
const props = defineProps({
open: {
required: false,
type: Boolean,
default: false
},
label: {
required: false,
type: String,
default: 'Select'
},
hover: {
required: false,
type: Boolean,
default: false
},
buttonClass: {
required: false,
default: ""
}
},
label: {
required: false,
type: String,
default: 'Select'
},
methods: {
toggle() {
if (this.open) {
this.triggerClose();
} else {
this.triggerOpen();
}
},
buttonClass: {
required: false,
default: ""
}
});
triggerOpen() {
this.$emit("open");
},
const dropdownElement = useTemplateRef("dropdown");
triggerClose() {
this.$emit("close");
},
onClickOutside(dropdownElement, event => handleOutsideClick(event))
handleOutsideClick(evt) {
if (this.open) {
if (!this.$el.contains(evt.target)) {
this.triggerClose();
}
}
}
},
function toggle() {
if (props.open) {
triggerClose();
} else {
triggerOpen();
}
}
mounted() {
document.addEventListener("click", this.handleOutsideClick);
},
function triggerOpen() {
emit("open");
}
beforeDestroy() {
document.removeEventListener("click", this.handleOutsideClick);
function triggerClose() {
emit("close");
}
function handleOutsideClick(evt) {
if (props.open) {
if (!dropdownElement.value.contains(evt.target)) {
triggerClose();
}
}
}
</script>

View File

@ -9,92 +9,87 @@
</transition>
</template>
<script>
<script setup>
import TWEEN from '@tweenjs/tween.js';
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
export default {
props: {
expandTime: {
type: Number,
default: 250
}
},
data() {
return {
animation: null
}
},
methods: {
cancel () {
if (this.animation) {
this.animation.stop();
this.animation = null;
}
},
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;
this.animation = new TWEEN.Tween({height: 0, paddingTop: 0, paddingBottom: 0})
.to({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}, this.expandTime)
.onUpdate(obj => {
element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px";
})
.onComplete(() => {
this.animation = null;
element.removeAttribute('style');
element.style.opacity = 0.99;
setTimeout(() => {
// Fixes odd drawing bug in Chrome
element.style.opacity = 1.0;
}, 1000);
done();
})
.start();
},
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';
this.animation = new TWEEN.Tween({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom})
.to({height: 0, paddingTop: 0, paddingBottom: 0}, this.expandTime)
.onUpdate(obj => {
element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px";
})
.onComplete(() => {
this.animation = null;
element.removeAttribute('style');
done();
})
.start();
}
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>

View File

@ -6,6 +6,8 @@
<script>
import { computed } from "vue";
class IconData {
constructor(iconicIcon, dataAttributes) {
this.iconicIcon = iconicIcon;
@ -49,6 +51,7 @@
};
export default {
emits: ["click"],
props: {
icon: {
validator: (i) => iconMap[i] !== undefined
@ -66,38 +69,21 @@
}
},
data() {
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 {
injectedSvg: null
}
},
computed: {
iconData() {
return iconMap[this.icon];
},
sizeData() {
return sizeMap[this.size];
},
iconClasses() {
return [
this.sizeData.bulmaIconClass,
this.sizeData.customIconClass
]
},
iconicSize() {
return this.sizeData.iconicSize;
},
iconicIcon() {
return this.iconData.iconicIcon;
},
iconicAttributes() {
return this.iconData.dataAttributes;
iconClasses,
iconData,
sizeData,
iconicAttributes,
iconicIcon,
iconicSize
}
}
}

View File

@ -1,9 +1,11 @@
<template>
<svg v-bind="svgAttributes" v-html="svgContent" :class="calculatedClasses"></svg>
<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";
@ -68,29 +70,15 @@
}
},
data() {
return {
calculatedClasses: []
}
},
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);
computed: {
svgData() {
return iconMap[this.icon];
},
svgAttributes() {
return this.svgData.attributes;
},
svgName() {
return this.svgAttributes['data-icon'];
},
svgContent() {
let content = String(this.svgData.content);
for (let idRep of this.svgData.idReplacements) {
for (let idRep of svgData.value.idReplacements) {
let newId = `__new_id_${globalIdCounter}`;
globalIdCounter += 1;
@ -98,57 +86,70 @@
}
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;
}
}
},
methods: {
rebind() {
function setupSvgApi(name) {
const apis = APIS;
if (apis && this.svgName && apis[this.svgName]) {
const iconApi = apis[this.svgName](this.$el);
for (let func in iconApi) this.$el[func] = iconApi[func]
if (apis && apis[name]) {
const iconApi = apis[name](svgElement.value);
for (let func in iconApi) svgElement.value[func] = iconApi[func]
} else {
this.$el.update = function() {}
svgElement.value.update = function() {}
}
this.calculatedClasses = (this.svgAttributes.class || "").split(" ");
this.calculatedClasses.push(`iconic-${this.size}`);
if (this.iconSizeOverride) {
this.calculatedClasses.push(`iconic-icon-${this.iconSizeOverride}`);
}
if (this.displaySizeOverride) {
this.calculatedClasses.push(`iconic-size-${this.displaySizeOverride}`);
}
this.$el.update();
svgElement.value.update();
}
},
created() {
if (LOADED_APIS[this.svgName] !== true) {
for (let sb of this.svgData.scriptBlocks) {
new Function(sb)(window);
}
LOADED_APIS[this.svgName] = true;
function updateScripts() {
ensureSvgApi(svgName.value, svgData.value.scriptBlocks);
setupSvgApi(svgName.value);
}
},
mounted() {
onMounted(() => {
updateScripts();
});
this.$watch(
function() { return [this.$attrs, this.icon, this.fluid] },
function() {
this.rebind()
},
{
immediate: true
}
);
onUpdated(() => {
updateScripts();
});
return {
svgData,
svgAttributes,
svgName,
svgContent,
calculatedClasses
};
}
}

View File

@ -4,10 +4,7 @@
</div>
</template>
<script>
export default {
}
<script setup>
</script>
<style lang="scss" scoped>

View File

@ -1,6 +1,6 @@
<template>
<div ref="container">
<div ref="modal" :class="['popup', 'modal', { 'is-wide': wide, 'is-active': open && error === null }]">
<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">
@ -13,50 +13,40 @@
<section class="modal-card-body">
<slot></slot>
</section>
<footer class="modal-card-foot">
<slot name="footer">
</slot>
</footer>
</div>
</div>
</div>
</Teleport>
</template>
<script>
<script setup>
import { mapState } from "vuex";
import { computed } from "vue";
import { useAppConfigStore } from "../stores/appConfig";
export default {
props: {
open: {
type: Boolean,
default: false
},
title: String,
wide: {
type: Boolean,
default: false
}
const emit = defineEmits(["dismiss"]);
const props = defineProps({
open: {
type: Boolean,
default: false
},
mounted() {
this.$root.$el.appendChild(this.$refs.modal);
},
beforeDestroy() {
this.$refs.container.appendChild(this.$refs.modal);
},
computed: {
...mapState([
'error'
])
},
methods: {
close() {
this.$emit("dismiss");
}
},
components: {
title: String,
wide: {
type: Boolean,
default: false
}
});
const appConfig = useAppConfigStore();
const error = computed(() => appConfig.error);
function close() {
emit("dismiss");
}
</script>

View File

@ -51,46 +51,27 @@
</nav>
</template>
<script>
<script setup>
import { ref, watch } from "vue";
import UserLogin from "./UserLogin";
import { mapState } from "vuex";
import { storeToRefs } from "pinia";
import { useAppConfigStore } from "../stores/appConfig";
import { swUpdate } from "../lib/ServiceWorker";
import { useRoute } from "vue-router";
export default {
data() {
return {
menuActive: false
};
},
const appConfig = useAppConfigStore();
const menuActive = ref(false);
const route = useRoute();
const { isAdmin, isLoggedIn, updateAvailable, user } = storeToRefs(appConfig);
computed: {
...mapState([
'route',
'user',
'updateAvailable'
])
},
methods: {
updateApp() {
swUpdate();
}
},
watch: {
route() {
this.menuActive = false;
},
user() {
this.menuActive = false;
}
},
components: {
UserLogin
}
function updateApp() {
swUpdate();
}
watch(
() => [route, appConfig.user],
() => menuActive.value = false
);
</script>

View File

@ -1,7 +1,7 @@
<template>
<nav v-show="totalPages > 1 || showWithSinglePage" class="pagination" role="navigation" :aria-label="pagedItemName + ' page navigation'">
<a class="pagination-previous" :title="isFirstPage ? 'This is the first page' : ''" :disabled="isFirstPage" @click.prevent="changePage(currentPage - 1)">Previous</a>
<a class="pagination-next" :title="isLastPage ? 'This is the last page' : ''" :disabled="isLastPage" @click.prevent="changePage(currentPage + 1)">Next page</a>
<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>
@ -11,96 +11,81 @@
</nav>
</template>
<script>
<script setup>
export default {
props: {
pagedItemName: {
required: false,
type: String,
default: ''
},
import { computed } from "vue";
currentPage: {
required: true,
type: Number
},
const emit = defineEmits(["changePage"]);
totalPages: {
required: true,
type: Number
},
const props = defineProps({
pagedItemName: {
required: false,
type: String,
default: ''
},
pageWindow: {
required: false,
type: Number,
default: 4
},
currentPage: {
required: true,
type: Number
},
pageOuterWindow: {
required: false,
type: Number,
default: 1
},
totalPages: {
required: true,
type: Number
},
showWithSinglePage: {
required: false,
type: Boolean,
default: false
}
},
pageWindow: {
required: false,
type: Number,
default: 4
},
computed: {
pageItems() {
const items = new Set();
pageOuterWindow: {
required: false,
type: Number,
default: 1
},
for (let x = 0; x < this.pageOuterWindow; x++) {
items.add(x + 1);
items.add(this.totalPages - x);
}
const start = this.currentPage - Math.ceil(this.pageWindow / 2);
const end = this.currentPage + Math.floor(this.pageWindow / 2);
for (let x = start; x <= end; x++) {
items.add(x);
}
let emptySpace = -1;
const finalList = [];
[...items.values()].filter(p => p > 0 && p <= this.totalPages).sort((a, b) => a - b).forEach((p, idx, list) => {
finalList.push(p);
if (list[idx + 1] && list[idx + 1] !== p + 1) {
finalList.push(emptySpace--);
}
});
return finalList;
},
isLastPage() {
return this.currentPage === this.totalPages;
},
isFirstPage() {
return this.currentPage === 1;
}
},
methods: {
changePage(idx) {
this.$emit("changePage", idx);
}
}
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>
<style lang="scss" scoped>
ul.pagination {
}
</style>

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

@ -9,103 +9,91 @@
</span>
</template>
<script>
<script setup>
import { computed, ref, useTemplateRef } from "vue";
export default {
props: {
starCount: {
required: false,
type: Number,
default: 5
},
const emit = defineEmits(["update:modelValue"]);
readonly: {
required: false,
type: Boolean,
default: false
},
const props = defineProps({
starCount: {
required: false,
type: Number,
default: 5
},
step: {
required: false,
type: Number,
default: 0.5
},
readonly: {
required: false,
type: Boolean,
default: false
},
value: {
required: false,
type: Number,
default: 0
}
},
step: {
required: false,
type: Number,
default: 0.5
},
data() {
return {
temporaryValue: null
};
},
computed: {
ratingPercent() {
return ((this.value || 0) / this.starCount) * 100.0;
},
temporaryPercent() {
if (this.temporaryValue !== null) {
return (this.temporaryValue / this.starCount) * 100.0;
} else {
return null;
}
},
filledStyle() {
const width = this.temporaryPercent || this.ratingPercent;
return {
width: width + "%"
};
}
},
methods: {
handleClick(evt) {
if (this.temporaryValue !== null) {
this.$emit("input", this.temporaryValue);
}
},
handleMousemove(evt) {
if (this.readonly) {
return;
}
const wrapperBox = this.$refs.wrapper.getBoundingClientRect();
const wrapperWidth = wrapperBox.right - wrapperBox.left;
const mousePosition = evt.clientX;
if (mousePosition > wrapperBox.left && mousePosition < wrapperBox.right) {
const filledRatio = ((mousePosition - wrapperBox.left) / wrapperWidth);
const totalSteps = this.starCount / this.step;
const filledSteps = Math.round(totalSteps * filledRatio);
this.temporaryValue = filledSteps * this.step;
}
},
handleMouseleave(evt) {
this.temporaryValue = null;
}
},
components: {
}
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>
@import "../styles/variables";
@use "bulma/sass/utilities" as bulma;
span.rating {
position: relative;
@ -125,7 +113,7 @@
}
.filled-set {
color: $yellow;
color: bulma.$yellow;
position: absolute;
top: 0;
left: 0;

View File

@ -6,61 +6,45 @@
</div>
</template>
<script>
<script setup>
import { ref } from "vue";
import debounce from "lodash/debounce";
export default {
props: {
placeholder: {
required: false,
type: String,
default: ""
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 })
value: {
required: false,
type: String,
default: ""
}
},
function userUpdateText(newText) {
if (text.value !== newText) {
text.value = newText;
triggerInput();
}
}
data() {
return {
text: null
};
},
computed: {
},
methods: {
triggerInput: debounce(function() {
this.$emit("input", this.text);
}, 250, {leading: false, trailing: true}),
userUpdateText(text) {
if (this.text !== text) {
this.text = text;
this.triggerInput();
}
},
propUpdateText(text) {
if (this.text === null && this.text !== text) {
this.text = text;
}
}
},
created() {
this.$watch("value",
val => this.propUpdateText(val),
{
immediate: true
}
);
function propUpdateText(newText) {
if (text.value === null && text.value !== newText) {
text.value = newText;
}
}

View File

@ -2,84 +2,71 @@
<div class="tag-editor control">
<input ref="input" type="text" class="input" :value="tagText" @input="inputHandler" @focus="getFocus" @blur="loseFocus">
<div class="tags">
<span v-for="t in value" :key="t" class="tag">{{t}}</span>
<span v-for="t in modelValue" :key="t" class="tag">{{t}}</span>
</div>
</div>
</template>
<script>
<script setup>
export default {
props: {
value: {
required: true,
type: Array
}
},
import {computed, nextTick, ref, useTemplateRef} from "vue";
data() {
return {
hasFocus: false
};
},
const emit = defineEmits(["update:modelValue"]);
computed: {
tagText() {
return this.value.join(" ");
}
},
const props = defineProps({
modelValue: {
required: true,
type: Array
}
});
watch: {
},
const hasFocus = ref(false);
const tagText = computed(() => props.modelValue.join(" "));
const inputElement = useTemplateRef("input");
methods: {
inputHandler(el) {
let str = el.target.value;
this.checkInput(str);
this.$nextTick(() => {
el.target.value = str;
});
},
function inputHandler(el) {
let str = el.target.value;
checkInput(str);
nextTick(() => {
el.target.value = str;
});
}
checkInput(str) {
if (this.hasFocus) {
const m = str.match(/\S\s+\S*$/);
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 (!this.arraysEqual(newTags, this.value)) {
this.$emit("input", newTags);
}
},
getFocus() {
this.hasFocus = true;
},
loseFocus() {
this.hasFocus = false;
this.checkInput(this.$refs.input.value);
},
arraysEqual(arr1, arr2) {
if(arr1.length !== arr2.length)
return false;
for(let i = arr1.length; i--;) {
if(arr1[i] !== arr2[i])
return false;
}
return true;
}
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

@ -2,8 +2,8 @@
<div class="field">
<label v-if="label.length" class="label is-small-mobile">{{ label }}</label>
<div :class="controlClasses">
<textarea v-if="isTextarea" :class="inputClasses" :value="value" @input="input" :disabled="disabled"></textarea>
<input v-else :type="type" :class="inputClasses" :value="value" @input="input" :disabled="disabled">
<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">
@ -12,81 +12,67 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
label: {
required: false,
type: String,
default: ""
},
value: {
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
}
},
import { computed } from "vue";
computed: {
isTextarea() {
return this.type === "textarea";
},
controlClasses() {
return [
"control",
{
"has-icons-right": this.validationError !== null
}
]
},
inputClasses() {
return [
"is-small-mobile",
{
"textarea": this.isTextarea,
"input": !this.isTextarea,
"is-danger": this.validationError !== null
}
]
},
helpMessage() {
return this.validationError;
},
helpClasses() {
return [
"help",
{
"is-danger": this.validationError !== null
}
];
}
},
methods: {
input(evt) {
this.$emit("input", evt.target.value);
}
}
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>

View File

@ -6,16 +6,14 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
errors: {
required: false,
type: Object,
default: {}
}
}
const props = defineProps({
errors: {
required: false,
type: Object,
default: {}
}
});
</script>

View File

@ -62,11 +62,14 @@
<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">
@ -82,6 +85,7 @@
<button type="button" class="button is-danger" @click="removeUnit(unit)">X</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -96,14 +100,18 @@
<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>
@ -136,99 +144,82 @@
</div>
</template>
<script>
<script setup>
import { computed } from "vue";
import api from "../lib/Api";
import { mapState } from "vuex";
import { mapState } from "pinia";
import { useNutrientStore } from "../stores/nutrient";
import { useLoadResource } from "../lib/useLoadResource";
export default {
props: {
food: {
required: true,
type: Object
},
validationErrors: {
required: false,
type: Object,
default: {}
},
action: {
required: false,
type: String,
default: "Editing"
}
const nutrientStore = useNutrientStore();
const nutrients = computed(() => nutrientStore.nutrientList);
const { loadResource } = useLoadResource();
const props = defineProps({
food: {
required: true,
type: Object
},
data() {
return {
};
validationErrors: {
required: false,
type: Object,
default: {}
},
computed: {
...mapState({
nutrients: 'nutrientList'
}),
visibleFoodUnits() {
return this.food.food_units.filter(iu => iu._destroy !== true);
},
hasNdbn() {
return this.food.ndbn !== null;
}
},
methods: {
addUnit() {
this.food.food_units.push({
id: null,
name: null,
gram_weight: null
});
},
removeUnit(unit) {
if (unit.id) {
unit._destroy = true;
} else {
const idx = this.food.food_units.findIndex(i => i === unit);
this.food.food_units.splice(idx, 1);
}
},
removeNdbn() {
this.food.ndbn = null;
this.food.usda_food_name = null;
this.food.ndbn_units = [];
},
updateSearchItems(text) {
return api.getUsdaFoodSearch(text)
.then(data => data.map(f => {
return {
name: f.name,
ndbn: f.ndbn,
description: ["#", f.ndbn, ", Cal:", f.kcal, ", Carbs:", f.carbohydrates, ", Fat:", f.lipid, ", Protein:", f.protein].join("")
}
}));
},
searchItemSelected(food) {
this.food.ndbn = food.ndbn;
this.food.usda_food_name = food.name;
this.food.ndbn_units = [];
this.loadResource(
api.postIngredientSelectNdbn(this.food)
.then(i => Object.assign(this.food, i))
);
},
},
components: {
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>

View File

@ -53,24 +53,20 @@
</div>
</template>
<script>
<script setup>
import { mapState } from "vuex";
import { computed } from "vue";
import { useNutrientStore } from "../stores/nutrient";
export default {
props: {
food: {
required: true,
type: Object
}
},
computed: {
...mapState({
nutrients: 'nutrientList'
}),
const props = defineProps({
food: {
required: true,
type: Object
}
}
});
const nutrientStore = useNutrientStore();
const nutrients = computed(() => nutrientStore.nutrientList);
</script>

View File

@ -31,27 +31,21 @@
</template>
<script>
<script setup>
import RecipeEdit from "./RecipeEdit";
export default {
props: {
log: {
required: true,
type: Object
},
validationErrors: {
required: false,
type: Object,
default: {}
},
const props = defineProps({
log: {
required: true,
type: Object
},
components: {
RecipeEdit
}
}
validationErrors: {
required: false,
type: Object,
default: {}
},
});
</script>

View File

@ -21,7 +21,7 @@
</div>
<div class="field-body">
<div class="field">
<app-rating readonly :value="log.rating"></app-rating>
<app-rating readonly :model-value="log.rating"></app-rating>
</div>
</div>
</div>
@ -44,22 +44,16 @@
</div>
</template>
<script>
<script setup>
import RecipeShow from "./RecipeShow";
export default {
props: {
log: {
required: true,
type: Object
}
},
components: {
RecipeShow
const props = defineProps({
log: {
required: true,
type: Object
}
}
});
</script>

View File

@ -19,37 +19,27 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
note: {
required: true,
type: Object
}
},
import { computed } from "vue";
data() {
return {
};
},
computed: {
canSave() {
return this.note && this.note.content && this.note.content.length;
}
},
methods: {
save() {
this.$emit("save", this.note);
},
cancel() {
this.$emit("cancel");
}
}
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>

View File

@ -71,6 +71,7 @@
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Heading</td>
<td>
@ -155,6 +156,7 @@ _underline_
</p>
</td>
</tr>
</tbody>
</table>
<h3 class="title is-3">Basic Example</h3>
@ -204,67 +206,50 @@ _underline_
</div>
</template>
<script>
<script setup>
import autosize from "autosize";
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";
export default {
props: {
recipe: {
required: true,
type: Object
},
forLogging: {
required: false,
type: Boolean,
default: false
}
const props = defineProps({
recipe: {
required: true,
type: Object
},
data() {
return {
stepPreviewCache: null,
isDescriptionHelpOpen: false
};
},
computed: {
stepPreview() {
if (this.stepPreviewCache === null) {
return this.recipe.rendered_steps;
} else {
return this.stepPreviewCache;
}
}
},
methods: {
updatePreview: debounce(function() {
api.postPreviewSteps(this.recipe.step_text)
.then(data => this.stepPreviewCache = data.rendered_steps)
.catch(err => this.stepPreviewCache = "?? Error ??");
}, 750)
},
watch: {
'recipe.step_text': function() {
this.updatePreview();
}
},
mounted() {
//autosize(this.$refs.step_text_area);
},
components: {
RecipeEditIngredientEditor
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>

View File

@ -12,18 +12,22 @@
</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>
@ -39,172 +43,155 @@
</div>
</template>
<script>
<script setup>
import { computed, ref } from "vue";
import { useMediaQueryStore } from "../stores/mediaQuery";
import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
import { mapState } from "vuex";
const mediaQueryStore = useMediaQueryStore();
export default {
props: {
ingredients: {
required: true,
type: Array
}
},
data() {
return {
isBulkEditing: false,
bulkEditText: null
};
},
computed: {
...mapState({
isMobile: state => state.mediaQueries.mobile
}),
bulkIngredientPreview() {
if (this.bulkEditText === null) {
return [];
}
const regex = /^\s*(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,|]+?|.+\|)(?:,\s*([^|]*?))?(?:\s*\[(\d+)\]\s*)?$/i;
const magicFunc = function(str) {
if (str === "-") {
return "";
} else {
return str;
}
};
const parsed = [];
const lines = this.bulkEditText.replace("\r", "").split("\n");
for (let line of lines) {
if (line.length === 0) { continue; }
const match = line.match(regex);
if (match) {
const matchedName = match[3].replace(/\|\s*$/, "");
let item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(matchedName), preparation: magicFunc(match[4]), id: match[5] || null};
parsed.push(item);
} else {
parsed.push(null);
}
}
return parsed;
},
visibleIngredients() {
return this.ingredients.filter(i => i._destroy !== true);
}
},
methods: {
createIngredient() {
const sort_orders = this.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
};
},
addIngredient() {
this.ingredients.push(this.createIngredient());
},
deleteFood(food) {
if (food.id) {
food._destroy = true;
} else {
const idx = this.ingredients.findIndex(i => i === food);
this.ingredients.splice(idx, 1);
}
},
bulkEditIngredients() {
this.isBulkEditing = true;
let text = [];
for (let item of this.visibleIngredients) {
text.push(
item.quantity + " " +
(item.units || "-") + " " +
(item.name.indexOf(",") >= 0 ? item.name + "|" : item.name) +
(item.preparation ? (", " + item.preparation) : "") +
(item.id ? (" [" + item.id + "]") : "")
);
}
this.bulkEditText = text.join("\n");
},
cancelBulkEditing() {
this.isBulkEditing = false;
},
saveBulkEditing() {
const parsed = this.bulkIngredientPreview.filter(i => i !== null);
const existing = [...this.ingredients];
const newList = [];
for (let parsedIngredient of parsed) {
let newIngredient = null;
if (parsedIngredient.id !== null) {
let intId = parseInt(parsedIngredient.id);
let exIdx = existing.findIndex(i => i.id === intId);
if (exIdx >= 0) {
let ex = existing[exIdx];
if (ex.name === parsedIngredient.name) {
newIngredient = ex;
existing.splice(exIdx, 1);
}
}
}
if (newIngredient === null) {
newIngredient = this.createIngredient();
}
newIngredient.quantity = parsedIngredient.quantity;
newIngredient.units = parsedIngredient.units;
newIngredient.name = parsedIngredient.name;
newIngredient.preparation = parsedIngredient.preparation;
newList.push(newIngredient);
}
for (let oldExisting of existing.filter(i => i.id !== null)) {
newList.push({id: oldExisting.id, _destroy: true});
}
this.ingredients.splice(0);
let sortIdx = 0;
for (let n of newList) {
n.sort_order = sortIdx++;
this.ingredients.push(n);
}
this.isBulkEditing = false;
}
},
components: {
RecipeEditIngredientItem
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>

View File

@ -44,55 +44,50 @@
</div>
</template>
<script>
<script setup>
import { useTemplateRef, watch } from "vue";
import api from "../lib/Api";
export default {
props: {
ingredient: {
required: true,
type: Object
},
showLabels: {
required: false,
type: Boolean,
default: false
}
const emit = defineEmits(["deleteFood"]);
const props = defineProps({
ingredient: {
required: true,
type: Object
},
showLabels: {
required: false,
type: Boolean,
default: false
}
});
methods: {
deleteFood(ingredient) {
this.$emit("deleteFood", ingredient);
},
const autocompleteElement = useTemplateRef("autocomplete");
updateSearchItems(text) {
return api.getSearchIngredients(text);
},
watch(props.ingredient, (val) => {
if (props.ingredient.ingredient && props.ingredient.ingredient.name !== val) {
props.ingredient.ingredient_id = null;
props.ingredient.ingredient = null;
}
});
searchItemSelected(ingredient) {
this.ingredient.ingredient_id = ingredient.id;
this.ingredient.ingredient = ingredient;
this.ingredient.name = ingredient.name;
},
function deleteFood(ingredient) {
emit("deleteFood", ingredient);
}
nameClick() {
if (this.ingredient.ingredient_id === null && this.ingredient.name !== null && this.ingredient.name.length > 2) {
this.$refs.autocomplete.updateOptions(this.ingredient.name);
}
}
},
function updateSearchItems(text) {
return api.getSearchIngredients(text);
}
watch: {
'ingredient.name': function(val) {
if (this.ingredient.ingredient && this.ingredient.ingredient.name !== val) {
this.ingredient.ingredient_id = null;
this.ingredient.ingredient = null;
}
}
},
function searchItemSelected(ingredient) {
props.ingredient.ingredient_id = ingredient.id;
props.ingredient.ingredient = ingredient;
props.ingredient.name = ingredient.name;
}
components: {
function nameClick() {
if (props.ingredient.ingredient_id === null && props.ingredient.name !== null && props.ingredient.name.length > 2) {
autocompleteElement.updateOptions(props.ingredient.name);
}
}
@ -100,10 +95,10 @@
<style lang="scss" scoped>
@import "../styles/variables";
@use "bulma/sass/utilities" as bulma;
.edit-ingredient-item {
border-bottom: solid 1px $grey-light;
border-bottom: solid 1px bulma.$grey-light;
margin-bottom: 1.25rem;
&:last-child {

View File

@ -38,7 +38,7 @@
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 taskLists" :key="tl.id" @click="addRecipeToList(tl)">
<button class="button primary" v-for="tl in taskStore.taskLists" :key="tl.id" @click="addRecipeToList(tl)">
{{tl.name}}
</button>
</app-dropdown>
@ -65,14 +65,18 @@
<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>
@ -146,154 +150,122 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import api from "../lib/Api";
import { mapActions, mapMutations, mapState } from "vuex";
import { useTaskStore } from "../stores/task";
export default {
props: {
recipe: {
required: true,
type: Object
}
},
const taskStore = useTaskStore();
const router = useRouter();
data() {
return {
showNutrition: false,
showConvertDialog: false,
addToTasksMenuOpen: false,
const props = defineProps({
recipe: {
required: true,
type: Object
}
});
scaleValue: '1',
systemConvertValue: "",
unitConvertValue: "",
const showNutrition = ref(false);
const showConvertDialog = ref(false);
const addToTasksMenuOpen = ref(false);
scaleOptions: [
'1/4',
'1/3',
'1/2',
'2/3',
'3/4',
'1',
'1 1/2',
'2',
'3',
'4'
]
};
},
const scaleValue = ref('1');
const systemConvertValue = ref('');
const unitConvertValue = ref('');
computed: {
...mapState([
'taskLists'
]),
timeDisplay() {
let a = this.formatMinutes(this.recipe.active_time);
const t = this.formatMinutes(this.recipe.total_time);
const scaleOptions = [
'1/4',
'1/3',
'1/2',
'2/3',
'3/4',
'1',
'1 1/2',
'2',
'3',
'4'
];
if (a) {
a = ` (${a} active)`;
}
const timeDisplay = computed(() => {
let a = formatMinutes(props.recipe.active_time);
const t = formatMinutes(props.recipe.total_time);
return t + a;
},
if (a) {
a = ` (${a} active)`;
}
sourceUrl() {
try {
return new URL(this.recipe.source);
} catch(err) {
return null;
}
},
return t + a;
});
isSourceUrl() {
return this.sourceUrl !== null;
},
const sourceUrl = computed(() => {
try {
return new URL(props.recipe.source);
} catch(err) {
return null;
}
});
sourceText() {
if (this.isSourceUrl) {
return this.sourceUrl.host;
} else {
return this.source;
}
}
},
const isSourceUrl = computed(() => sourceUrl.value !== null);
const sourceText = computed(() => isSourceUrl.value ? sourceUrl.value.host : props.recipe.source);
watch: {
recipe: {
handler: function(r) {
if (r) {
this.scaleValue = r.converted_scale || '1';
this.systemConvertValue = r.converted_system;
this.unitConvertValue = r.converted_unit;
}
},
immediate: true
}
},
watch(props.recipe, (r) => {
if (r) {
scaleValue.value = r.converted_scale || '1';
systemConvertValue.value = r.converted_system;
unitConvertValue.value = r.converted_unit;
}
}, { immediate: true });
methods: {
...mapActions([
'ensureTaskLists'
]),
...mapMutations([
'setCurrentTaskList'
]),
onMounted(() => {
taskStore.ensureTaskLists();
});
addRecipeToList(list) {
console.log(list);
api.addRecipeToTaskList(list.id, this.recipe.id)
function addRecipeToList(list) {
api.addRecipeToTaskList(list.id, props.recipe.id)
.then(() => {
this.setCurrentTaskList(list);
this.$router.push({name: 'task_lists'})
taskStore.setCurrentTaskList(list);
router.push({name: 'task_lists'})
});
},
convert() {
this.showConvertDialog = false;
this.$router.push({name: 'recipe', query: { scale: this.scaleValue, system: this.systemConvertValue, unit: this.unitConvertValue }});
},
}
roundValue(v) {
return parseFloat(v).toFixed(2);
},
function convert() {
showConvertDialog.value = false;
router.push({name: 'recipe', query: { scale: scaleValue.value, system: systemConvertValue.value, unit: unitConvertValue.value }});
}
formatMinutes(min) {
if (min) {
const partUnits = [
{unit: "d", minutes: 60 * 24},
{unit: "h", minutes: 60},
{unit: "m", minutes: 1}
];
function roundValue(v) {
return parseFloat(v).toFixed(2);
}
const parts = [];
let remaining = min;
function formatMinutes(min) {
if (min) {
const partUnits = [
{unit: "d", minutes: 60 * 24},
{unit: "h", minutes: 60},
{unit: "m", minutes: 1}
];
for (let unit of partUnits) {
let val = Math.floor(remaining / unit.minutes);
remaining = remaining % unit.minutes;
const parts = [];
let remaining = min;
if (val > 0) {
parts.push(`${val} ${unit.unit}`);
}
}
for (let unit of partUnits) {
let val = Math.floor(remaining / unit.minutes);
remaining = remaining % unit.minutes;
return parts.join(" ");
} else {
return "";
if (val > 0) {
parts.push(`${val} ${unit.unit}`);
}
}
},
mounted() {
this.ensureTaskLists();
return parts.join(" ");
} else {
return "";
}
}
</script>
<style lang="scss" scoped>
</style>
</style>

View File

@ -24,39 +24,42 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
taskItem: {
required: true,
type: Object
}
},
import { onMounted, useTemplateRef } from "vue";
methods: {
inputKeydown(evt) {
switch (evt.key) {
case "Enter":
evt.preventDefault();
this.save();
}
},
const emit = defineEmits(["save"]);
const props = defineProps({
taskItem: {
required: true,
type: Object
}
});
save() {
this.$emit("save", this.taskItem);
},
const nameElement = useTemplateRef("nameInput");
focus() {
this.$refs.nameInput.focus();
}
},
onMounted(() => focus());
mounted() {
this.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>

View File

@ -60,138 +60,106 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, useTemplateRef } from "vue";
import * as Errors from '../lib/Errors';
import { mapActions } from "vuex";
import cloneDeep from "lodash/cloneDeep";
import { useTaskStore } from "../stores/task";
import { useLoadResource } from "../lib/useLoadResource";
import TaskItemEdit from "./TaskItemEdit";
const newItemTemplate = function(listId) {
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: listId,
task_list_id: null,
name: '',
quantity: '',
completed: false
};
};
}
export default {
props: {
taskList: {
required: true,
type: Object
}
},
data() {
return {
showAddItem: false,
newItem: null,
newItemValidationErrors: {}
};
},
computed: {
completedItemCount() {
return this.taskList === null ? 0 : this.taskList.task_items.filter(i => i.completed).length;
},
uncompletedItemCount() {
return this.taskList === null ? 0 : this.taskList.task_items.filter(i => !i.completed).length;
},
completedTaskItems() {
return (this.taskList ? this.taskList.task_items : []).filter(i => i.completed);
},
uncompletedTaskItems() {
return (this.taskList ? this.taskList.task_items : []).filter(i => !i.completed);
},
taskItems() {
return this.uncompletedTaskItems.concat(this.completedTaskItems);
}
},
methods: {
...mapActions([
'createTaskItem',
'updateTaskItem',
'deleteTaskItems',
'completeTaskItems'
]),
save() {
this.loadResource(
this.createTaskItem(this.newItem)
function save() {
newItem.value.task_list_id = props.taskList.id;
loadResource(
taskStore.createTaskItem(newItem.value)
.then(() => {
this.newItem = newItemTemplate(this.taskList.id);
this.$refs.itemEdit.focus();
newItem.value = newItemTemplate();
itemEditElement.value.focus();
})
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.newItemValidationErrors = err.validationErrors()))
)
},
.catch(Errors.onlyFor(Errors.ApiValidationError, err => newItemValidationErrors.value = err.validationErrors()))
)
}
toggleItem(i) {
this.loadResource(
this.completeTaskItems({
taskList: this.taskList,
taskItems: [i],
completed: !i.completed
})
);
},
function toggleItem(i) {
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: [i],
completed: !i.completed
})
);
}
toggleShowAddItem() {
this.newItem = newItemTemplate(this.taskList.id);
this.showAddItem = !this.showAddItem;
},
function toggleShowAddItem() {
newItem.value = newItemTemplate();
showAddItem.value = !showAddItem.value;
}
completeAllItems() {
const toComplete = this.taskList.task_items.filter(i => !i.completed);
this.loadResource(
this.completeTaskItems({
taskList: this.taskList,
taskItems: toComplete,
completed: true
})
)
},
function completeAllItems() {
const toComplete = props.taskList.task_items.filter(i => !i.completed);
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: toComplete,
completed: true
})
)
}
unCompleteAllItems() {
const toUnComplete = this.taskList.task_items.filter(i => i.completed);
this.loadResource(
this.completeTaskItems({
taskList: this.taskList,
taskItems: toUnComplete,
completed: false
})
)
},
function unCompleteAllItems() {
const toUnComplete = props.taskList.task_items.filter(i => i.completed);
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: toUnComplete,
completed: false
})
)
}
deleteCompletedItems() {
this.loadResource(
this.deleteTaskItems({
taskList: this.taskList,
taskItems: this.taskList.task_items.filter(i => i.completed)
})
);
},
},
components: {
TaskItemEdit
}
function deleteCompletedItems() {
loadResource(
taskStore.deleteTaskItems({
taskList: props.taskList,
taskItems: props.taskList.task_items.filter(i => i.completed)
})
);
}
</script>
<style lang="scss" scoped>
@import "../styles/variables";
.columns {
margin-top: 0;
margin-bottom: 0;

View File

@ -14,58 +14,55 @@
</template>
<script>
<script setup>
export default {
props: {
taskList: {
type: Object,
required: true
},
import { ref } from "vue";
active: {
type: Boolean,
required: false,
default: false
}
const emit = defineEmits(["select", "delete"]);
const props = defineProps({
taskList: {
type: Object,
required: true
},
data() {
return {
hovering: false,
confirmingDelete: false
};
},
methods: {
selectList() {
this.$emit("select", this.taskList);
},
deleteList() {
this.confirmingDelete = false;
this.$emit("delete", this.taskList);
}
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>
@import "~styles/variables";
@use "bulma/sass/utilities" as bulma;
@use 'sass:color';
div.dropdown-item {
cursor: pointer;
&.hovered {
color: $black;
background-color: $background;
color: bulma.$black;
background-color: bulma.$background;
}
&.is-active {
color: $link-invert;
background-color: $link;
color: color.invert(bulma.$link);
background-color: bulma.$link;
}
}

View File

@ -14,34 +14,32 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
taskList: {
required: true,
type: Object
},
const emit = defineEmits(["save"]);
validationErrors: {
required: false,
type: Object,
default: function() { return {}; }
}
const props = defineProps({
taskList: {
required: true,
type: Object
},
methods: {
save() {
this.$emit("save");
},
validationErrors: {
required: false,
type: Object,
default: function() { return {}; }
}
});
nameKeydownHandler(evt) {
switch (evt.key) {
case "Enter":
evt.preventDefault();
this.save();
}
}
function save() {
emit("save");
}
function nameKeydownHandler(evt) {
switch (evt.key) {
case "Enter":
evt.preventDefault();
save();
}
}

View File

@ -5,10 +5,7 @@
</div>
</template>
<script>
<script setup>
export default {
}
</script>

View File

@ -7,7 +7,7 @@
</p>
<p>
Parsley is released under the MIT License. All code &copy; Dan Elbert 2020.
Parsley is released under the MIT License. All code &copy; Dan Elbert 2024.
</p>
<p>
@ -19,11 +19,7 @@
</div>
</template>
<script>
export default {
}
<script setup>
</script>

View File

@ -2,11 +2,8 @@
</template>
<script>
<script setup>
export default {
}
</script>

View File

@ -29,24 +29,21 @@
</template>
<script>
<script setup>
import { onBeforeMount, ref } from "vue";
import { useLoadResource } from "../lib/useLoadResource";
import api from "../lib/Api";
export default {
data() {
return {
userList: []
};
},
const { loadResource } = useLoadResource();
const userList = ref([]);
created() {
this.loadResource(
onBeforeMount(() => {
loadResource(
api.getAdminUserList()
.then(list => this.userList = list)
);
}
}
.then(list => userList.value = list)
);
});
</script>

View File

@ -40,93 +40,64 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from "vue";
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
input: '',
outputUnit: '',
ingredient_name: '',
ingredient: null,
density: '',
output: '',
errors: {}
};
},
const { loadResource } = useLoadResource();
computed: {
inputErrors() {
if (this.errors.input && this.errors.input.length > 0) {
return this.errors.input.join(", ");
} else {
return null;
}
},
const input = ref("");
const outputUnit = ref("");
const ingredient_name = ref("");
const ingredient = ref(null);
const density = ref("");
const output = ref("");
const errors = ref({});
outputUnitErrors() {
if (this.errors.output_unit && this.errors.output_unit.length > 0) {
return this.errors.output_unit.join(", ");
} else {
return null;
}
},
const inputErrors = computed(() => getErrors("input"));
const outputUnitErrors = computed(() => getErrors("output_unit"));
const densityErrors = computed(() => getErrors("density"));
densityErrors() {
if (this.errors.density && this.errors.density.length > 0) {
return this.errors.density.join(", ");
} else {
return null;
}
}
},
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);
methods: {
updateSearchItems(text) {
return api.getSearchIngredients(text);
},
watch(ingredient_name, function(val) {
if (ingredient.value && ingredient.value.name !== val) {
ingredient.value = null;
}
});
searchItemSelected(ingredient) {
this.ingredient = ingredient || null;
this.ingredient_name = ingredient.name || null;
this.density = ingredient.density || null;
},
watch(
[input, outputUnit, density, ingredient],
() => updateOutput()
);
updateOutput: debounce(function() {
if (this.input && this.input.length > 0) {
this.loadResource(api.getCalculate(this.input, this.outputUnit, this.ingredient ? this.ingredient.ingredient_id : null, this.density)
.then(data => {
this.output = data.output;
this.errors = data.errors;
})
);
}
}, 500)
},
function updateSearchItems(text) {
return api.getSearchIngredients(text);
}
watch: {
'ingredient_name': function(val) {
if (this.ingredient && this.ingredient.name !== val) {
this.ingredient = null;
}
}
},
function searchItemSelected(ingredient) {
ingredient.value = ingredient || null;
ingredient_name.value = ingredient.name || null;
density.value = ingredient.density || null;
}
created() {
this.$watch(
function() {
return [this.input, this.outputUnit, this.density, this.ingredient];
},
function() {
this.updateOutput();
}
)
},
components: {
function getErrors(type) {
if (errors.value[type] && errors.value[type].length > 0) {
return errors.value[type].join(", ");
} else {
return null;
}
}

View File

@ -7,41 +7,33 @@
<food-show :food="food"></food-show>
</div>
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_food', params: { id: foodId }}">Edit</router-link>
<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>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute } from "vue-router";
import FoodShow from "./FoodShow";
import { mapState } from "vuex";
import api from "../lib/Api";
import { useLoadResource } from "../lib/useLoadResource";
import { useAppConfigStore } from "../stores/appConfig";
export default {
data: function () {
return {
food: null
}
},
const { loadResource } = useLoadResource();
const appConfig = useAppConfigStore();
const route = useRoute();
computed: {
...mapState({
foodId: state => state.route.params.id,
})
},
const food = ref(null);
const foodId = computed(() => route.params.id);
created() {
this.loadResource(
api.getFood(this.foodId)
.then(data => { this.food = data; return data; })
);
},
components: {
FoodShow
}
}
onBeforeMount(() => {
loadResource(
api.getFood(foodId.value)
.then(data => { food.value = data; return data; })
);
});
</script>

View File

@ -9,66 +9,59 @@
</div>
</template>
<script>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import FoodEdit from "./FoodEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
food: {
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: []
},
validationErrors: {}
}
},
const { loadResource } = useLoadResource();
const router = useRouter();
methods: {
save() {
this.validationErrors = {}
this.loadResource(
api.postFood(this.food)
.then(() => this.$router.push('/foods'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
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: []
});
components: {
FoodEdit
}
function save() {
validationErrors.value = {}
loadResource(
api.postFood(food)
.then(() => router.push('/foods'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>

View File

@ -14,48 +14,37 @@
</div>
</template>
<script>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import FoodEdit from "./FoodEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
export default {
data: function () {
return {
food: null,
validationErrors: {}
};
},
const { loadResource } = useLoadResource();
const router = useRouter();
const route = useRoute();
computed: {
...mapState({
foodId: state => state.route.params.id,
})
},
const food = ref(null);
const validationErrors = ref({});
const foodId = computed(() => route.params.id);
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.patchFood(this.food)
.then(() => this.$router.push({name: 'food', params: {id: this.foodId }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
onBeforeMount(() => {
loadResource(
api.getFood(foodId.value)
.then(data => { food.value = data; return data; })
);
});
created() {
this.loadResource(
api.getFood(this.foodId)
.then(data => { this.food = data; return data; })
);
},
components: {
FoodEdit
}
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>

View File

@ -3,7 +3,7 @@
<h1 class="title">Ingredients</h1>
<div class="buttons">
<router-link v-if="isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
<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>
@ -35,12 +35,14 @@
<td>{{i.kcal}}</td>
<td>{{i.density}}</td>
<td>
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_food', params: { id: i.id } }">
<app-icon icon="pencil"></app-icon>
</router-link>
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteFood(i)">
<app-icon icon="x"></app-icon>
</button>
<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>
@ -49,114 +51,80 @@
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="food" @changePage="changePage"></app-pager>
<div class="buttons">
<router-link v-if="isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
</div>
<app-confirm :open="showConfirmFoodDelete" :message="confirmFoodDeleteMessage" :cancel="foodDeleteCancel" :confirm="foodDeleteConfirm"></app-confirm>
<app-confirm :open="showConfirmFoodDelete" title="Delete Ingredient?" :message="confirmFoodDeleteMessage" @cancel="foodDeleteCancel" @confirm="foodDeleteConfirm"></app-confirm>
</div>
</template>
<script>
<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";
export default {
data() {
return {
foodData: null,
foodForDeletion: null,
search: {
page: 1,
per: 25,
name: null
}
};
},
const appConfig = useAppConfigStore();
const { loadResource } = useLoadResource();
computed: {
foods() {
if (this.foodData) {
return this.foodData.foods;
} else {
return [];
}
},
const foodData = ref(null);
const foodForDeletion = ref(null);
const search = reactive({
page: 1,
per: 25,
name: null
});
totalPages() {
if (this.foodData) {
return this.foodData.total_pages
}
return 0;
},
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 "??";
}
});
currentPage() {
if (this.foodData) {
return this.foodData.current_page
}
return 0;
},
const getList = debounce(function() {
return loadResource(
api.getFoodList(search.page, search.per, search.name)
.then(data => foodData.value = data)
);
}, 500, {leading: true, trailing: true});
showConfirmFoodDelete() {
return this.foodForDeletion !== null;
},
confirmFoodDeleteMessage() {
if (this.foodForDeletion !== null) {
return `Are you sure you want to delete ${this.foodForDeletion.name}?`;
} else {
return "??";
}
watch(search,
() => getList(),
{
deep: true,
immediate: true
}
},
);
methods: {
changePage(idx) {
this.search.page = idx;
},
function changePage(idx) {
search.page = idx;
}
getList: debounce(function() {
return this.loadResource(
api.getFoodList(this.search.page, this.search.per, this.search.name)
.then(data => this.foodData = data)
);
}, 500, {leading: true, trailing: true}),
function deleteFood(food) {
foodForDeletion.value = food;
}
deleteFood(food) {
this.foodForDeletion = food;
},
function foodDeleteCancel() {
foodForDeletion.value = null;
}
foodDeleteCancel() {
this.foodForDeletion = null;
},
foodDeleteConfirm() {
if (this.foodForDeletion !== null) {
this.loadResource(
api.deleteFood(this.foodForDeletion.id).then(res => {
this.foodForDeletion = null;
return this.getList();
})
);
console.log("This is where the thing happens!!");
this.foodForDeletion = null;
}
}
},
created() {
this.$watch("search",
() => this.getList(),
{
deep: true,
immediate: true
}
function foodDeleteConfirm() {
if (foodForDeletion.value !== null) {
loadResource(
api.deleteFood(foodForDeletion.value.id).then(res => {
foodForDeletion.value = null;
return getList();
})
);
},
components: {
}
}

View File

@ -8,42 +8,34 @@
</div>
<div class="buttons">
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_log', params: { id: logId }}">Edit</router-link>
<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>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute } from "vue-router";
import LogShow from "./LogShow";
import { mapState } from "vuex";
import api from "../lib/Api";
import { useLoadResource } from "../lib/useLoadResource";
import { useAppConfigStore } from "../stores/appConfig";
export default {
data: function () {
return {
log: null
}
},
const { loadResource } = useLoadResource();
const route = useRoute();
const appConfig = useAppConfigStore();
computed: {
...mapState({
logId: state => state.route.params.id,
})
},
const log = ref(null);
const logId = computed(() => route.params.id);
created() {
this.loadResource(
api.getLog(this.logId)
.then(data => { this.log = data; return data; })
);
},
components: {
LogShow
}
}
onBeforeMount(() => {
loadResource(
api.getLog(logId.value)
.then(data => { log.value = data; return data; })
);
});
</script>

View File

@ -16,54 +16,44 @@
</div>
</template>
<script>
<script setup>
import { computed, onBeforeMount, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import LogEdit from "./LogEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
validationErrors: {},
log: {
date: null,
rating: null,
notes: null,
recipe: null
}
}
},
const { loadResource } = useLoadResource();
const route = useRoute();
const router = useRouter();
computed: {
...mapState({
recipeId: state => state.route.params.recipeId,
})
},
const validationErrors = ref({});
const log = reactive({
date: null,
rating: null,
notes: null,
recipe: null
});
methods: {
save() {
this.log.original_recipe_id = this.recipeId;
this.validationErrors = {};
const recipeId = computed(() => route.params.recipeId);
this.loadResource(
api.postLog(this.log)
.then(() => this.$router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
onBeforeMount(() => {
loadResource(
api.getRecipe(recipeId.value, null, null, null, data => log.recipe = data)
);
});
created() {
this.loadResource(
api.getRecipe(this.recipeId, null, null, null, data => this.log.recipe = data)
);
},
function save() {
log.original_recipe_id = recipeId.value;
validationErrors.value = {};
components: {
LogEdit
}
loadResource(
api.postLog(log)
.then(() => router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>

View File

@ -16,48 +16,38 @@
</div>
</template>
<script>
<script setup>
import { mapState } from "vuex";
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";
export default {
data() {
return {
validationErrors: {},
log: null
}
},
const { loadResource } = useLoadResource();
const route = useRoute();
const router = useRouter();
computed: {
...mapState({
logId: state => state.route.params.id,
})
},
const validationErrors = ref({});
const log = ref(null);
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.patchLog(this.log)
.then(() => this.$router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
const logId = computed(() => route.params.id);
created() {
this.loadResource(
api.getLog(this.logId)
.then(data => { this.log = data; return data; })
);
},
onBeforeMount(() => {
loadResource(
api.getLog(logId.value)
.then(data => { log.value = data; return data; })
);
});
components: {
LogEdit
}
function save() {
validationErrors.value = {};
loadResource(
api.patchLog(log.value)
.then(() => router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>

View File

@ -5,86 +5,64 @@
</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 :value="l.rating" readonly></app-rating></td>
<td><app-rating :model-value="l.rating" readonly></app-rating></td>
<td>{{l.notes}}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
logData: null,
search: {
page: 1,
per: 25
}
};
},
const { loadResource } = useLoadResource();
computed: {
logs() {
if (this.logData) {
return this.logData.logs;
} else {
return [];
}
},
const logData = ref(null);
const search = reactive({
page: 1,
per: 25
});
totalPages() {
if (this.logData) {
return this.logData.total_pages
}
return 0;
},
const logs = computed(() => logData.value?.logs || []);
const totalPages = computed(() => logData.value?.total_pages || 0);
const currentPage = computed(() => logData.value?.current_page || 0);
currentPage() {
if (this.logData) {
return this.logData.current_page
}
return 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
}
},
);
methods: {
changePage(idx) {
this.search.page = idx;
},
getList: debounce(function() {
this.loadResource(
api.getLogList(this.search.page, this.search.per)
.then(data => this.logData = data)
);
}, 500, {leading: true, trailing: true})
},
created() {
this.$watch("search",
() => this.getList(),
{
deep: true,
immediate: true
}
);
}
function changePage(idx) {
search.page = idx;
}
</script>

View File

@ -9,12 +9,15 @@
</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 }}
@ -28,68 +31,60 @@
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
<script setup>
import { onBeforeMount, ref } from "vue";
import api from "../lib/Api";
import NoteEdit from "./NoteEdit";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
notes: [],
editNote: null
};
},
const { loadResource } = useLoadResource();
const notes = ref([]);
const editNote = ref(null);
methods: {
refreshList() {
this.loadResource(
api.getNoteList()
.then(data => this.notes = data)
);
},
onBeforeMount(() => {
refreshList();
});
addNote() {
this.editNote = { id: null, content: "" };
},
function refreshList() {
loadResource(
api.getNoteList()
.then(data => notes.value = data)
);
}
saveNote() {
this.loadResource(
api.postNote(this.editNote)
function addNote() {
editNote.value = { id: null, content: "" };
}
function saveNote() {
loadResource(
api.postNote(editNote.value)
.then(() => {
this.editNote = null;
return this.refreshList();
editNote.value = null;
return refreshList();
})
);
},
);
}
cancelNote() {
this.editNote = null;
},
function cancelNote() {
editNote.value = null;
}
deleteNote(n) {
this.loadResource(
api.deleteNote(n)
function deleteNote(n) {
loadResource(
api.deleteNote(n)
.then(() => {
return this.refreshList();
return refreshList();
})
);
}
},
created() {
this.refreshList();
},
components: {
NoteEdit
}
);
}
</script>

View File

@ -17,63 +17,53 @@
<recipe-show :recipe="recipe"></recipe-show>
</div>
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_recipe', params: { id: recipeId }}">Edit</router-link>
<router-link v-if="appConfig.isLoggedIn" class="button" :to="{name: 'edit_recipe', params: { id: recipeId }}">Edit</router-link>
<router-link class="button" to="/">Back</router-link>
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from "vue";
import { useRoute } from "vue-router";
import RecipeShow from "./RecipeShow";
import { mapState } from "vuex";
import api from "../lib/Api";
import { useLoadResource } from "../lib/useLoadResource";
import { useAppConfigStore } from "../stores/appConfig";
export default {
data: function () {
return {
recipe: null,
showNutrition: false
const appConfig = useAppConfigStore();
const route = useRoute();
const { loadResource } = useLoadResource();
const recipe = ref(null);
const recipeId = computed(() => route.params.id);
const scale = computed(() => route.query.scale || null);
const system = computed(() => route.query.system || null);
const unit = computed(() => route.query.unit || null);
const isScaled = computed(() => recipe.value?.converted_scale?.length !== undefined && recipe.value.converted_scale.length > 0 && recipe.value.converted_scale !== "1");
watch(
() => route.query,
() => refreshData(),
{ immediate: true }
);
watch(
() => recipe.value,
(newRecipe) => {
if (newRecipe) {
document.title = `${newRecipe.name}`;
}
}
},
)
computed: {
...mapState({
recipeId: state => state.route.params.id,
routeQuery: state => state.route.query,
scale: state => state.route.query.scale || null,
system: state => state.route.query.system || null,
unit: state => state.route.query.unit || null
}),
isScaled() {
return this.recipe.converted_scale !== null && this.recipe.converted_scale.length > 0 && this.recipe.converted_scale !== "1";
}
},
watch: {
routeQuery() {
this.refreshData();
}
},
methods: {
refreshData() {
this.loadResource(
api.getRecipe(this.recipeId, this.scale, this.system, this.unit, data => this.recipe = data)
);
}
},
created() {
this.refreshData();
},
components: {
RecipeShow
}
function refreshData() {
loadResource(
api.getRecipe(recipeId.value, scale.value, system.value, unit.value, data => recipe.value = data)
);
}
</script>
<style lang="scss" scoped>
</style>
</style>

View File

@ -13,44 +13,38 @@
</div>
</template>
<script>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import RecipeEdit from "./RecipeEdit";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
validationErrors: {},
recipe: {
name: null,
source: null,
description: null,
yields: null,
total_time: null,
active_time: null,
step_text: null,
tags: [],
ingredients: []
}
}
},
const router = useRouter();
const { loadResource } = useLoadResource();
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.postRecipe(this.recipe)
.then(() => this.$router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
const validationErrors = ref({});
const recipe = ref({
name: null,
source: null,
description: null,
yields: null,
total_time: null,
active_time: null,
step_text: null,
tags: [],
ingredients: []
});
components: {
RecipeEdit
}
function save() {
validationErrors.value = {};
loadResource(
api.postRecipe(recipe.value)
.then(() => router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>

View File

@ -18,49 +18,39 @@
</div>
</template>
<script>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLoadResource } from "../lib/useLoadResource";
import RecipeEdit from "./RecipeEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
export default {
data: function () {
return {
recipe: null,
validationErrors: {}
}
},
const { loadResource } = useLoadResource();
const route = useRoute();
const router = useRouter();
const recipe = ref(null);
const validationErrors = ref({});
computed: {
...mapState({
recipeId: state => state.route.params.id,
})
},
const recipeId = computed(() => route.params.id);
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.patchRecipe(this.recipe)
.then(() => this.$router.push({name: 'recipe', params: {id: this.recipeId }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
onBeforeMount(() => {
loadResource(
api.getRecipe(recipeId.value, null, null, null, data => { recipe.value = data; return data; })
);
});
created() {
this.loadResource(
api.getRecipe(this.recipeId, null, null, null, data => { this.recipe = data; return data; })
);
},
components: {
RecipeEdit
}
function save() {
validationErrors.value = {};
loadResource(
api.patchRecipe(recipe.value)
.then(() => router.push({name: 'recipe', params: {id: recipeId.value }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>
<style lang="scss" scoped>

View File

@ -2,7 +2,7 @@
<div>
<h1 class="title">Recipes</h1>
<router-link v-if="isLoggedIn" :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
@ -11,7 +11,7 @@
<table class="table is-fullwidth" :class="{ small: mediaQueries.touch }">
<table class="table is-fullwidth" :class="{ small: isTouch }">
<thead>
<tr>
<th v-for="h in tableHeader" :key="h.name">
@ -25,10 +25,10 @@
</tr>
<tr>
<td>
<app-search-text placeholder="search names" :value="search.name" @input="setSearchName($event)"></app-search-text>
<app-search-text placeholder="search names" :value="search.name" @update:modelValue="setSearchName($event)"></app-search-text>
</td>
<td>
<app-search-text placeholder="search tags" :value="search.tags" @input="setSearchTags($event)"></app-search-text>
<app-search-text placeholder="search tags" :value="search.tags" @update:modelValue="setSearchTags($event)"></app-search-text>
</td>
<td colspan="5"></td>
</tr>
@ -42,17 +42,19 @@
</div>
</td>
<td>
<app-rating v-if="r.rating !== null" :value="r.rating" readonly></app-rating>
<app-rating v-if="r.rating !== null" :model-value="r.rating" readonly></app-rating>
<span v-else>--</span>
</td>
<td>{{ r.yields }}</td>
<td class="recipe-time">{{ formatRecipeTime(r.total_time, r.active_time) }}</td>
<td><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td>
<td>
<app-dropdown hover v-if="isLoggedIn" class="is-right">
<button slot="button" class="button is-small">
<app-icon icon="menu"></app-icon>
</button>
<app-dropdown hover v-if="appConfig.isLoggedIn" class="is-right">
<template #button>
<button class="button is-small">
<app-icon icon="menu"></app-icon>
</button>
</template>
<div class="dropdown-item">
<router-link :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary is-fullwidth">
@ -83,239 +85,217 @@
</div>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
<app-confirm :open="showConfirmRecipeDelete" :message="confirmRecipeDeleteMessage" :cancel="recipeDeleteCancel" :confirm="recipeDeleteConfirm"></app-confirm>
<app-confirm :open="showConfirmRecipeDelete" title="Delete Recipe?" :message="confirmRecipeDeleteMessage" @cancel="recipeDeleteCancel" @confirm="recipeDeleteConfirm"></app-confirm>
</div>
</template>
<script>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { useRouter } from 'vue-router'
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { mapMutations, mapState } from "vuex";
import AppLoading from "./AppLoading";
import { useAppConfigStore } from "../stores/appConfig";
import { useMediaQueryStore } from "../stores/mediaQuery";
import { useLoadResource } from "../lib/useLoadResource";
export default {
props: {
searchQuery: {
type: Object,
required: false,
default: {}
}
},
const appConfig = useAppConfigStore();
const mediaQueries = useMediaQueryStore();
const { loadResource, localLoading } = useLoadResource();
const router = useRouter();
data() {
return {
recipeData: null,
recipeForDeletion: null
};
},
const props = defineProps({
searchQuery: {
type: Object,
required: false,
default: {}
}
});
computed: {
...mapState([
"mediaQueries"
]),
const tableHeader = [
{name: 'name', label: 'Name', sort: true},
{name: 'tags', label: 'Tags', sort: false},
{name: 'rating', label: 'Rating', sort: true},
{name: 'yields', label: 'Yields', sort: false},
{name: 'total_time', label: 'Time', sort: true},
{name: 'created_at', label: 'Created', sort: true}
];
search() {
return {
name: this.searchQuery.name || null,
tags: this.searchQuery.tags || null,
column: this.searchQuery.column || "created_at",
direction: this.searchQuery.direction || "desc",
page: this.searchQuery.page || 1,
per: this.searchQuery.per || 25
}
},
const recipeData = ref(null);
const recipeForDeletion = ref(null);
const isTouch = computed(() => mediaQueries.touch);
recipes() {
if (this.recipeData) {
return this.recipeData.recipes;
} else {
return [];
}
},
const search = computed(() => ({
name: props.searchQuery.name || null,
tags: props.searchQuery.tags || null,
column: props.searchQuery.column || "created_at",
direction: props.searchQuery.direction || "desc",
page: props.searchQuery.page || 1,
per: props.searchQuery.per || 25
}));
tableHeader() {
return [
{name: 'name', label: 'Name', sort: true},
{name: 'tags', label: 'Tags', sort: false},
{name: 'rating', label: 'Rating', sort: true},
{name: 'yields', label: 'Yields', sort: false},
{name: 'total_time', label: 'Time', sort: true},
{name: 'created_at', label: 'Created', sort: true}
]
},
const recipes = computed(() => {
if (recipeData.value) {
return recipeData.value.recipes;
} else {
return [];
}
});
totalPages() {
if (this.recipeData) {
return this.recipeData.total_pages;
}
return 0;
},
const totalPages = computed(() => {
if (recipeData.value) {
return recipeData.value.total_pages;
}
return 0;
});
currentPage() {
if (this.recipeData) {
return this.recipeData.current_page;
}
return 0;
},
const currentPage = computed(() => {
if (recipeData.value) {
return recipeData.value.current_page;
}
return 0;
});
showConfirmRecipeDelete() {
return this.recipeForDeletion !== null;
},
const showConfirmRecipeDelete = computed(() => recipeForDeletion.value !== null);
confirmRecipeDeleteMessage() {
if (this.showConfirmRecipeDelete) {
return `Are you sure you want to delete ${this.recipeForDeletion.name}?`;
} else {
return "??";
}
}
},
const confirmRecipeDeleteMessage = computed(() => {
if (showConfirmRecipeDelete.value) {
return `Are you sure you want to delete ${recipeForDeletion.value.name}?`;
} else {
return "??";
}
});
methods: {
...mapMutations([
"setInitialLoad"
]),
buildQueryParams() {
return {
name: this.searchQuery.name,
tags: this.searchQuery.tags,
column: this.searchQuery.column,
direction: this.searchQuery.direction,
page: this.searchQuery.page,
per: this.searchQuery.per
}
},
watch(search, () => {
getList().then(() => appConfig.initialLoad = true);
}, {
deep: true,
immediate: true
});
redirectToParams(params) {
const rParams = {};
function getList() {
return loadResource(
api.getRecipeList(search.value.page, search.value.per, search.value.column, search.value.direction, search.value.name, search.value.tags, data => recipeData.value = data)
);
}
if (params.name) {
rParams.name = params.name;
}
if (params.tags) {
rParams.tags = params.tags;
}
if (params.column) {
rParams.column = params.column;
}
if (params.direction) {
rParams.direction = params.direction;
}
if (params.page) {
rParams.page = params.page;
}
if (params.per) {
rParams.per = params.per;
}
this.$router.push({name: 'recipeList', query: rParams});
},
changePage(idx) {
const p = this.buildQueryParams();
p.page = idx;
this.redirectToParams(p);
},
setSort(col) {
const p = this.buildQueryParams();
if (p.column === col) {
p.direction = p.direction === "desc" ? "asc" : "desc";
} else {
p.column = col;
p.direction = "asc";
}
this.redirectToParams(p);
},
setSearchName(name) {
const p = this.buildQueryParams();
if (name !== p.name) {
p.name = name;
p.page = null;
this.redirectToParams(p);
}
},
setSearchTags(tags) {
const p = this.buildQueryParams();
if (tags !== p.tags) {
p.tags = tags;
p.page = null;
this.redirectToParams(p);
}
},
deleteRecipe(recipe) {
this.recipeForDeletion = recipe;
},
recipeDeleteConfirm() {
if (this.recipeForDeletion !== null) {
this.loadResource(
api.deleteRecipe(this.recipeForDeletion.id).then(() => {
this.recipeForDeletion = null;
return this.getList();
})
);
}
},
recipeDeleteCancel() {
this.recipeForDeletion = null;
},
getList() {
return this.loadResource(
api.getRecipeList(this.search.page, this.search.per, this.search.column, this.search.direction, this.search.name, this.search.tags, data => this.recipeData = data)
);
},
formatRecipeTime(total, active) {
let str = "";
if (total && total > 0) {
str += total;
}
if (active && active > 0) {
if (str.length) {
str += " (" + active + ")";
} else {
str += active;
}
}
return str;
}
},
created() {
this.$watch("search",
() => {
this.getList().then(() => this.setInitialLoad(true));
},
{
deep: true,
immediate: true
}
);
},
components: {
AppLoading
function buildQueryParams() {
return {
name: props.searchQuery.name,
tags: props.searchQuery.tags,
column: props.searchQuery.column,
direction: props.searchQuery.direction,
page: props.searchQuery.page,
per: props.searchQuery.per
}
}
function redirectToParams(params) {
const rParams = {};
if (params.name) {
rParams.name = params.name;
}
if (params.tags) {
rParams.tags = params.tags;
}
if (params.column) {
rParams.column = params.column;
}
if (params.direction) {
rParams.direction = params.direction;
}
if (params.page) {
rParams.page = params.page;
}
if (params.per) {
rParams.per = params.per;
}
router.push({name: 'recipeList', query: rParams});
}
function changePage(idx) {
const p = buildQueryParams();
p.page = idx;
redirectToParams(p);
}
function setSort(col) {
const p = buildQueryParams();
if (p.column === col) {
p.direction = p.direction === "desc" ? "asc" : "desc";
} else {
p.column = col;
p.direction = "asc";
}
redirectToParams(p);
}
function setSearchName(name) {
const p = buildQueryParams();
if (name !== p.name) {
p.name = name;
p.page = null;
redirectToParams(p);
}
}
function setSearchTags(tags) {
const p = buildQueryParams();
if (tags !== p.tags) {
p.tags = tags;
p.page = null;
redirectToParams(p);
}
}
function deleteRecipe(recipe) {
recipeForDeletion.value = recipe;
}
function recipeDeleteConfirm() {
if (recipeForDeletion.value !== null) {
loadResource(
api.deleteRecipe(recipeForDeletion.value.id).then(() => {
recipeForDeletion.value = null;
return getList();
})
);
}
}
function recipeDeleteCancel() {
recipeForDeletion.value = null;
}
function formatRecipeTime(total, active) {
let str = "";
if (total && total > 0) {
str += total;
}
if (active && active > 0) {
if (str.length) {
str += " (" + active + ")";
} else {
str += active;
}
}
return str;
}
</script>
<style lang="scss" scoped>

View File

@ -22,15 +22,16 @@
</div>
</template>
<script>
<script setup>
import api from "../lib/Api";
import { computed, onBeforeMount, ref } from "vue";
import * as Errors from '../lib/Errors';
import { mapActions, mapMutations, mapState } from "vuex";
import { useTaskStore } from "../stores/task";
import TaskListMiniForm from "./TaskListMiniForm";
import TaskListDropdownItem from "./TaskListDropdownItem";
import TaskItemList from "./TaskItemList";
import { useLoadResource } from "../lib/useLoadResource";
const newListTemplate = function() {
return {
@ -38,84 +39,52 @@
};
};
export default {
data() {
return {
showListDropdown: false,
newList: newListTemplate(),
newListValidationErrors: {}
}
},
const { loadResource } = useLoadResource();
const taskStore = useTaskStore();
computed: {
...mapState([
'taskLists',
'currentTaskList'
]),
listSelectLabel() {
if (this.currentTaskList === null) {
return "Select or Create a List";
} else {
return this.currentTaskList.name;
}
}
},
const showListDropdown = ref(false);
const newList = ref(newListTemplate());
const newListValidationErrors = ref({});
methods: {
...mapActions([
'refreshTaskLists',
'createTaskList',
'deleteTaskList',
'deleteTaskItems',
'completeTaskItems'
]),
...mapMutations([
'setCurrentTaskList'
]),
selectList(list) {
this.setCurrentTaskList(list);
this.showListDropdown = false;
},
saveNewList() {
this.loadResource(
this.createTaskList(this.newList)
.then(() => this.showListDropdown = false)
.then(() => { this.newList = newListTemplate(); this.newListValidationErrors = {}; } )
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.newListValidationErrors = err.validationErrors()))
);
},
deleteList(list) {
this.loadResource(
this.deleteTaskList(list)
);
},
deleteAllItems() {
this.loadResource(
this.deleteTaskItems({
taskList: this.currentTaskList,
taskItems: this.currentTaskList.task_items
})
);
}
},
created() {
this.loadResource(
this.refreshTaskLists()
);
},
components: {
TaskListMiniForm,
TaskListDropdownItem,
TaskItemList
const taskLists = computed(() => taskStore.taskLists);
const currentTaskList = computed(() => taskStore.currentTaskList);
const listSelectLabel = computed(() => {
if (currentTaskList.value === null) {
return "Select or Create a List";
} else {
return currentTaskList.value.name;
}
});
onBeforeMount(() => {
loadResource(taskStore.refreshTaskLists());
});
function selectList(list) {
taskStore.setCurrentTaskList(list);
showListDropdown.value = false;
}
function saveNewList() {
loadResource(
taskStore.createTaskList(newList.value)
.then(() => showListDropdown.value = false)
.then(() => { newList.value = newListTemplate(); newListValidationErrors.value = {}; } )
.catch(Errors.onlyFor(Errors.ApiValidationError, err => newListValidationErrors.value = err.validationErrors()))
);
}
function deleteList(list) {
loadResource(taskStore.deleteTaskList(list));
}
function deleteAllItems() {
loadResource(
taskStore.deleteTaskItems({
taskList: currentTaskList.value,
taskItems: currentTaskList.value.task_items
})
);
}
</script>

View File

@ -13,41 +13,37 @@
</div>
</template>
<script>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import UserEdit from "./UserEdit";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
import { useCheckAuthentication } from "../lib/useCheckAuthentication";
export default {
data() {
return {
validationErrors: {},
userObj: {
username: '',
full_name: '',
email: '',
password: '',
password_confirmation: ''
}
}
},
const { loadResource } = useLoadResource();
const { checkAuthentication } = useCheckAuthentication(loadResource);
const router = useRouter();
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.postUser(this.userObj)
.then(() => this.checkAuthentication())
.then(() => this.$router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
const validationErrors = ref({});
const userObj = ref({
username: '',
full_name: '',
email: '',
password: '',
password_confirmation: ''
});
components: {
UserEdit
}
function save() {
validationErrors.value = {};
loadResource(
api.postUser(userObj.value)
.then(() => checkAuthentication())
.then(() => router.push('/'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>

View File

@ -13,63 +13,56 @@
</div>
</template>
<script>
<script setup>
import { ref, watch } from "vue";
import { useRouter } from "vue-router";
import UserEdit from "./UserEdit";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useAppConfigStore } from "../stores/appConfig";
import { useLoadResource } from "../lib/useLoadResource";
import { useCheckAuthentication } from "../lib/useCheckAuthentication";
export default {
data() {
return {
validationErrors: {},
userObj: null
}
},
const appConfig = useAppConfigStore();
const { loadResource } = useLoadResource();
const { checkAuthentication } = useCheckAuthentication(loadResource);
const router = useRouter();
created() {
this.refreshUser();
},
const validationErrors = ref({});
const userObj = ref(null);
watch: {
user() {
this.refreshUser();
}
},
watch(
() => appConfig.user,
() => refreshUser(),
{ immediate: true });
methods: {
refreshUser() {
if (this.user) {
this.userObj = {
username: this.user.username,
full_name: this.user.full_name,
email: this.user.email,
password: '',
password_confirmation: ''
};
} else {
this.userObj = null;
}
},
save() {
this.validationErrors = {};
this.loadResource(
api.patchUser(this.userObj)
.then(() => this.checkAuthentication())
.then(() => {
this.$router.push('/');
})
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
components: {
UserEdit
function refreshUser() {
if (appConfig.user) {
userObj.value = {
username: appConfig.user.username,
full_name: appConfig.user.full_name,
email: appConfig.user.email,
password: '',
password_confirmation: ''
};
} else {
userObj.value = null;
}
}
function save() {
validationErrors.value = {};
loadResource(
api.patchUser(userObj.value)
.then(() => checkAuthentication())
.then(() => {
router.push('/');
})
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>
<style lang="scss" scoped>

View File

@ -3,21 +3,19 @@
<app-text-field label="Username" v-model="userObj.username"></app-text-field>
<app-text-field label="Name" v-model="userObj.full_name"></app-text-field>
<app-text-field label="Email" v-model="userObj.email"></app-text-field>
<app-text-field label="Password" v-model="userObj.password"></app-text-field>
<app-text-field label="Password Confirmation" v-model="userObj.password_confirmation"></app-text-field>
<app-text-field type="password" label="Password" v-model="userObj.password"></app-text-field>
<app-text-field type="password" label="Password Confirmation" v-model="userObj.password_confirmation"></app-text-field>
</div>
</template>
<script>
<script setup>
export default {
props: {
userObj: {
required: true,
type: Object
}
const props = defineProps({
userObj: {
required: true,
type: Object
}
}
});
</script>

View File

@ -34,7 +34,7 @@
<button type="submit" class="button is-primary" :disabled="!enableSubmit">Login</button>
</div>
<div class="control">
<button class="button is-secondary" @click="showLogin = false">Cancel</button>
<button type="button" class="button is-secondary" @click="showLogin = false">Cancel</button>
</div>
</div>
@ -45,57 +45,44 @@
</div>
</template>
<script>
<script setup>
import api from "../lib/Api";
import { mapActions, mapState } from "vuex";
import { computed, nextTick, ref, useTemplateRef } from "vue";
import { useAppConfigStore } from "../stores/appConfig";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
showLogin: false,
error: '',
username: '',
password: ''
};
},
const appConfig = useAppConfigStore();
const { loadResource } = useLoadResource();
computed: {
...mapState([
'loginMessage'
]),
enableSubmit() {
return this.username !== '' && this.password !== '' && !this.isLoading;
}
},
const userNameElement = useTemplateRef("usernameInput");
methods: {
...mapActions([
'login'
]),
const showLogin = ref(false);
const error = ref('');
const username = ref("");
const password = ref("");
openDialog() {
this.showLogin = true;
this.$nextTick(() => this.$refs.usernameInput.focus());
},
const loginMessage = computed(() => appConfig.loginMessage);
const enableSubmit = computed(() => username.value !== "" && password.value !== "" && !appConfig.isLoading);
performLogin() {
if (this.username !== '' && this.password !== '') {
const params = {username: this.username, password: this.password};
function openDialog() {
showLogin.value = true;
nextTick(() => {
userNameElement.value.focus();
})
}
this.loadResource(
this.login(params)
function performLogin() {
if (username.value !== '' && password.value !== '') {
const params = {username: username.value, password: password.value};
loadResource(
appConfig.login(params)
.then(data => {
if (data.success) {
this.showLogin = false;
showLogin.value = false;
}
})
);
}
}
},
components: {
);
}
}

View File

@ -0,0 +1,17 @@
import * as cable from "@rails/actioncable";
let consumer = null;
function createChannel(baseUrl, ...args) {
if (consumer === null) {
if (baseUrl !== null && baseUrl.toString() !== "") {
consumer = cable.createConsumer(baseUrl);
} else {
consumer = cable.createConsumer();
}
}
return consumer.subscriptions.create(...args);
}
export { createChannel };

View File

@ -1,4 +1,4 @@
import config from '../config';
import config from './config';
import * as Errors from './Errors';
class Api {

View File

@ -0,0 +1,35 @@
function clickStrikeClick(evt) {
const isStrikable = el => el && el.tagName === "LI";
const strikeClass = "is-strikethrough";
let t = evt.target;
while (t !== null && t !== this && !isStrikable(t)) {
t = t.parentElement;
}
if (isStrikable(t)) {
const classList = t.className.split(" ");
const strIdx = classList.findIndex(c => c === strikeClass);
if (strIdx >= 0) {
classList.splice(strIdx, 1);
} else {
classList.push(strikeClass);
}
t.className = classList.join(" ");
}
}
export function installClickStrike(app) {
app.directive('click-strike', {
beforeMount(el) {
el.addEventListener("click", clickStrikeClick);
},
unmounted(el) {
el.removeEventListener("click", clickStrikeClick);
}
});
}

View File

@ -1,84 +0,0 @@
import Vue from 'vue';
import { mapActions, mapGetters, mapMutations, mapState } from 'vuex';
import api from "../lib/Api";
Vue.mixin({
data() {
return {
localLoadingCount: 0
};
},
computed: {
...mapGetters([
"isLoading",
"isLoggedIn",
"isAdmin"
]),
...mapState([
"user"
]),
localLoading() {
return this.localLoadingCount > 0;
}
},
methods: {
...mapActions([
'updateCurrentUser'
]),
...mapMutations([
'setError',
'setLoading'
]),
loadResource(promise) {
this.setLoading(true);
this.localLoadingCount = this.localLoadingCount + 1;
return promise
.catch(err => this.setError(err))
.then(res => {
this.setLoading(false);
this.localLoadingCount = this.localLoadingCount - 1;
return res;
});
},
checkAuthentication() {
return this.loadResource(this.updateCurrentUser());
}
}
});
function clickStrikeClick(evt) {
const isStrikable = el => el && el.tagName === "LI";
const strikeClass = "is-strikethrough";
let t = evt.target;
while (t !== null && t !== this && !isStrikable(t)) {
t = t.parentElement;
}
if (isStrikable(t)) {
const classList = t.className.split(" ");
const strIdx = classList.findIndex(c => c === strikeClass);
if (strIdx >= 0) {
classList.splice(strIdx, 1);
} else {
classList.push(strikeClass);
}
t.className = classList.join(" ");
}
}
Vue.directive('click-strike', {
bind(el) {
el.addEventListener("click", clickStrikeClick);
},
unbind(el) {
el.removeEventListener("click", clickStrikeClick);
}
});

View File

@ -1,4 +1,6 @@
import { useAppConfigStore } from '../stores/appConfig';
function trackInstall(worker, cb) {
worker.addEventListener('statechange', function() {
if (worker.state == 'installed') {
@ -18,7 +20,9 @@ function trackActive(worker, cb) {
export function swUpdate() {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg && reg.waiting) {
trackActive(reg.waiting, () => window.location.reload(true));
trackActive(reg.waiting, () => {
window.location.reload(true)
});
reg.waiting.postMessage("skipWaiting");
} else {
window.location.reload(true);
@ -27,10 +31,17 @@ export function swUpdate() {
}
export function swInit(store) {
export function swInit() {
const updateReady = () => store.commit("setUpdateAvailable", true);
const clearUpdateReady = () => store.commit("setUpdateAvailable", false);
const updateReady = () => {
const store = useAppConfigStore();
store.updateAvailable = true;
};
const clearUpdateReady = () => {
const store = useAppConfigStore();
store.updateAvailable = false;
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
@ -53,6 +64,4 @@ export function swInit(store) {
console.log('Registration failed with ' + error);
});
}
}

View File

@ -1,49 +0,0 @@
// Adds a module to a vuex store with a set of media query states
const defaultOptions = {
module: "mediaQueries"
};
// Hard coded values taken directly from Bulma css
const mobileBp = 768;
const desktopBp = 1024;
const widscreenBp = 1216;
const fullHdBp = 1408;
const mediaQueries = {
mobile: `screen and (max-width: ${mobileBp}px)`,
tablet: `screen and (min-width: ${mobileBp + 1}px)`,
tabletOnly: `screen and (min-width: ${mobileBp + 1}px) and (max-width: ${desktopBp - 1}px)`,
touch: `screen and (max-width: ${desktopBp - 1}px)`,
desktop: `screen and (min-width: ${desktopBp}px)`,
desktopOnly: `screen and (min-width: ${desktopBp}px) and (max-width: ${widscreenBp - 1}px)`,
widescreen: `screen and (min-width: ${widscreenBp}px)`,
widescreenOnly: `screen and (min-width: ${widscreenBp}px) and (max-width: ${fullHdBp - 1}px)`,
fullhd: `screen and (min-width: ${fullHdBp}px)`
};
export default function(store, options) {
let opts = Object.assign({}, defaultOptions, options || {});
const moduleName = opts.module;
const initialState = {};
for (let device in mediaQueries) {
const query = window.matchMedia(mediaQueries[device]);
query.onchange = (q) => {
store.commit(moduleName + "/MEDIA_QUERY_CHANGED", {mediaName: device, value: q.matches});
};
initialState[device] = query.matches;
}
store.registerModule(moduleName, {
namespaced: true,
state: initialState,
mutations: {
"MEDIA_QUERY_CHANGED" (state, data) {
state[data.mediaName] = data.value;
}
}
});
}

View File

@ -0,0 +1,9 @@
import { onBeforeUnmount } from "vue";
import autosize from 'autosize';
export function useAutosize(elementRef) {
autosize(elementRef.value);
onBeforeUnmount(() => {
autosize.destroy(elementRef.value);
});
}

View File

@ -0,0 +1,12 @@
import { useAppConfigStore } from "../stores/appConfig";
export function useCheckAuthentication(loadResource) {
const appConfig = useAppConfigStore();
const checkAuthentication = function() {
return loadResource(appConfig.updateCurrentUser());
}
return {
checkAuthentication
}
}

View File

@ -0,0 +1,10 @@
import { Group } from '@tweenjs/tween.js'
let group = null;
export function useGlobalTweenGroup() {
if (group === null) {
group = new Group();
}
return group;
}

View File

@ -0,0 +1,27 @@
import { computed, ref } from "vue";
import { useAppConfigStore } from "../stores/appConfig";
export function useLoadResource() {
const appConfig = useAppConfigStore();
const localLoadingCount = ref(0);
const localLoading = computed(() => localLoadingCount.value > 0);
const loadResource = async (promise) => {
appConfig.setLoading(true);
localLoadingCount.value = localLoadingCount.value + 1;
try {
return await promise;
} catch (error) {
appConfig.setError(error);
} finally {
appConfig.setLoading(false);
localLoadingCount.value = localLoadingCount.value - 1;
}
};
return {
loadResource,
localLoading,
localLoadingCount
};
}

View File

@ -1,17 +1,10 @@
import '../styles';
import "vue-resize/dist/vue-resize";
import Vue from 'vue'
import { sync } from 'vuex-router-sync';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { swInit } from "../lib/ServiceWorker";
import responsiveSync from "../lib/VuexResponsiveSync";
import VueProgressBar from "vue-progressbar";
import VueResize from "vue-resize";
import config from '../config';
import store from '../store';
import config from '../lib/config';
import { installClickStrike } from "../lib/ClickStrike";
import router from '../router';
import '../lib/GlobalMixins';
import App from '../components/App';
import AppAutocomplete from "../components/AppAutocomplete";
import AppConfirm from "../components/AppConfirm";
@ -30,57 +23,40 @@ import AppTagEditor from "../components/AppTagEditor";
import AppTextField from "../components/AppTextField";
import AppValidationErrors from "../components/AppValidationErrors";
Vue.component("AppAutocomplete", AppAutocomplete);
Vue.component("AppConfirm", AppConfirm);
Vue.component("AppDateTime", AppDateTime);
Vue.component("AppDatePicker", AppDatePicker);
Vue.component("AppDropdown", AppDropdown);
Vue.component("AppExpandTransition", AppExpandTransition);
Vue.component("AppIcon", AppIcon);
Vue.component("AppIconicIcon", AppIconicIcon);
Vue.component("AppModal", AppModal);
Vue.component("AppNavbar", AppNavbar);
Vue.component("AppPager", AppPager);
Vue.component("AppRating", AppRating);
Vue.component("AppSearchText", AppSearchText);
Vue.component("AppTagEditor", AppTagEditor);
Vue.component("AppTextField", AppTextField);
Vue.component("AppValidationErrors", AppValidationErrors);
import App from '../components/App.vue'
Vue.use(VueProgressBar, {
// color: '#bffaf3',
// failedColor: '#874b4b',
// thickness: '5px',
// transition: {
// speed: '0.2s',
// opacity: '0.6s',
// termination: 300
// },
// autoRevert: true,
// location: 'left',
// inverse: false
});
Vue.use(VueResize);
sync(store, router);
responsiveSync(store);
// Resize?
// Progressbar?
document.addEventListener('DOMContentLoaded', () => {
const appElement = document.getElementById('app');
if (!appElement) { return; }
const app = document.getElementById('app');
config.baseApiUrl = app.dataset.url;
config.baseApiUrl = appElement.dataset.url;
window.$vm = new Vue({
el: '#app',
store,
router,
render: createElement => createElement('App'),
mounted() {
this.$nextTick(() => swInit(store));
},
components: { App }
});
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
swInit();
installClickStrike(app);
app.component("AppAutocomplete", AppAutocomplete);
app.component("AppConfirm", AppConfirm);
app.component("AppDateTime", AppDateTime);
app.component("AppDatePicker", AppDatePicker);
app.component("AppDropdown", AppDropdown);
app.component("AppExpandTransition", AppExpandTransition);
app.component("AppIcon", AppIcon);
app.component("AppIconicIcon", AppIconicIcon);
app.component("AppModal", AppModal);
app.component("AppNavbar", AppNavbar);
app.component("AppPager", AppPager);
app.component("AppRating", AppRating);
app.component("AppSearchText", AppSearchText);
app.component("AppTagEditor", AppTagEditor);
app.component("AppTextField", AppTextField);
app.component("AppValidationErrors", AppValidationErrors);
app.mount(appElement);
});

View File

@ -1,49 +1,39 @@
import Vue from 'vue';
import Router from 'vue-router';
import { createRouter, createWebHashHistory } from "vue-router";
import { nextTick } from "vue";
import The404Page from './components/The404Page';
import TheAboutPage from './components/TheAboutPage';
import TheCalculator from './components/TheCalculator';
import The404Page from '../components/The404Page';
import TheAboutPage from '../components/TheAboutPage';
import TheCalculator from '../components/TheCalculator';
import TheLog from './components/TheLog';
import TheLogList from './components/TheLogList';
import TheLogCreator from './components/TheLogCreator';
import TheLogEditor from './components/TheLogEditor';
import TheLog from '../components/TheLog';
import TheLogList from '../components/TheLogList';
import TheLogCreator from '../components/TheLogCreator';
import TheLogEditor from '../components/TheLogEditor';
import TheFoodList from './components/TheFoodList';
import TheFood from "./components/TheFood";
import TheFoodEditor from "./components/TheFoodEditor";
import TheFoodCreator from "./components/TheFoodCreator";
import TheNotesList from './components/TheNotesList';
import TheRecipe from './components/TheRecipe';
import TheRecipeEditor from './components/TheRecipeEditor';
import TheRecipeCreator from './components/TheRecipeCreator';
import TheRecipeList from './components/TheRecipeList';
import TheFoodList from '../components/TheFoodList';
import TheFood from "../components/TheFood";
import TheFoodEditor from "../components/TheFoodEditor";
import TheFoodCreator from "../components/TheFoodCreator";
import TheNotesList from '../components/TheNotesList';
import TheRecipe from '../components/TheRecipe';
import TheRecipeEditor from '../components/TheRecipeEditor';
import TheRecipeCreator from '../components/TheRecipeCreator';
import TheRecipeList from '../components/TheRecipeList';
import TheTaskListList from './components/TheTaskListList';
import TheTaskListList from '../components/TheTaskListList';
import TheUserCreator from './components/TheUserCreator';
import TheUserEditor from './components/TheUserEditor';
import TheUserCreator from '../components/TheUserCreator';
import TheUserEditor from '../components/TheUserEditor';
import TheAdminUserList from './components/TheAdminUserList';
import TheAdminUserEditor from './components/TheAdminUserEditor';
import TheAdminUserList from '../components/TheAdminUserList';
import TheAdminUserEditor from '../components/TheAdminUserEditor';
import $store from './store';
import { useAppConfigStore } from "../stores/appConfig";
Vue.use(Router);
const router = new Router({
routes: []
});
router.afterEach((to, from) => {
if (to.meta.handleInitialLoad !== true && $store.state.initialLoad === false) {
$store.commit("setInitialLoad", true);
}
});
router.addRoutes(
[
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/recipes'
@ -144,10 +134,10 @@ router.addRoutes(
{
path: "/logout",
name: "logout",
beforeEnter: (to, from, next) => {
const $store = router.app.$store;
$store.dispatch("logout")
.then(() => next("/"));
beforeEnter: async (to, from, next) => {
const appConfig = useAppConfigStore();
await appConfig.logout();
return next("/");
}
},
@ -175,10 +165,22 @@ router.addRoutes(
},
{
path: '*',
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: The404Page
}
]
);
});
router.afterEach((to, from) => {
const appConfigStore = useAppConfigStore();
if (to.meta.handleInitialLoad !== true && appConfigStore.initialLoad === false) {
appConfigStore.initialLoad = true;
}
nextTick(() => {
document.title = to.meta.title || 'Parsley';
});
});
export default router;

View File

@ -1,236 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
import api from '../lib/Api';
Vue.use(Vuex);
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
initialLoad: false,
updateAvailable: false,
loadingCount: 0,
error: null,
authChecked: false,
user: null,
loginMessage: null,
taskLists: [],
currentTaskList: null,
nutrientList: {
kcal: { label: "Calories", unit: "kcal" },
protein: { label: "Protein", unit: "g" },
lipids: { label: "Fat", unit: "g" },
carbohydrates: { label: "Carbohydrates", unit: "g" },
water: { label: "Water", unit: "g" },
sugar: { label: "Sugar", unit: "g" },
fiber: { label: "Fiber", unit: "g" },
cholesterol: { label: "Cholesterol", unit: "mg" },
sodium: { label: "Sodium", unit: "mg" },
calcium: { label: "Calcium", unit: "mg" },
iron: { label: "Iron", unit: "mg" },
magnesium: { label: "Magnesium", unit: "mg" },
phosphorus: { label: "Phosphorus", unit: "mg" },
potassium: { label: "Potassium", unit: "mg" },
zinc: { label: "Zinc", unit: "mg" },
copper: { label: "Copper", unit: "mg" },
manganese: { label: "Manganese", unit: "mg" },
vit_a: { label: "Vitamin A", unit: "μg" },
vit_b6: { label: "Vitamin B6", unit: "mg" },
vit_b12: { label: "Vitamin B12", unit: "μg" },
vit_c: { label: "Vitamin C", unit: "mg" },
vit_d: { label: "Vitamin D", unit: "μg" },
vit_e: { label: "Vitamin E", unit: "mg" },
vit_k: { label: "Vitamin K", unit: "μg" },
ash: { label: "ash", unit: "g" }
}
},
getters: {
isLoading(state) {
return state.loadingCount > 0;
},
isLoggedIn(state) {
return state.user !== null;
},
isAdmin(state) {
return state.user !== null && state.user.admin === true;
}
},
mutations: {
setUpdateAvailable(state, value) {
state.updateAvailable = value;
},
setInitialLoad(state, value) {
state.initialLoad = value;
},
setLoading(state, value) {
if (value) {
state.loadingCount = state.loadingCount + 1;
} else {
state.loadingCount = state.loadingCount - 1;
}
state.loading = state.loadingCount !== 0;
},
setError(state, value) {
console.log(value);
state.error = value;
},
setUser(state, user) {
state.authChecked = true;
state.user = user;
},
setLoginMessage(state, msg) {
state.loginMessage = msg;
},
setTaskLists(state, lists) {
state.taskLists = lists || [];
},
setCurrentTaskList(state, list) {
state.currentTaskList = list || null;
},
appendTaskItem(state, item) {
const listId = item.task_list_id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
list.task_items.push(item);
}
},
replaceTaskItem(state, item) {
const listId = item.task_list_id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
const taskIdx = list.task_items.findIndex(i => i.id === item.id);
if (taskIdx >= 0) {
list.task_items.splice(taskIdx, 1, item);
}
}
},
removeTaskItems(state, payload) {
const listId = payload.taskList.id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
list.task_items = list.task_items.filter(item => {
return payload.taskItems.findIndex(i => i.id === item.id) === -1;
});
}
},
setTaskItemCompletion(state, payload) {
const listId = payload.taskList.id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
list.task_items.forEach(item => {
if (payload.taskItems.findIndex(i => i.id === item.id) >= 0) {
item.completed = payload.completed;
}
});
}
}
},
actions: {
updateCurrentUser({commit}) {
return api.getCurrentUser()
.then(user => {
commit("setUser", user);
return user;
});
},
login({commit}, authData) {
return api.postLogin(authData.username, authData.password)
.then(data => {
if (data.success) {
commit("setUser", data.user);
commit("setLoginMessage", null);
} else {
commit("setUser", null);
commit("setLoginMessage", data.message);
}
return data;
});
},
logout({commit}) {
return api.getLogout()
.then(() => {
commit("setUser", null);
});
},
refreshTaskLists({commit, state}) {
const cb = function(data) {
commit("setTaskLists", data);
let ctl = null;
if (state.currentTaskList) {
ctl = data.find(l => l.id === state.currentTaskList.id);
}
ctl = ctl || data[0] || null;
commit("setCurrentTaskList", ctl);
};
return api.getTaskLists(cb)
},
ensureTaskLists({dispatch, state}) {
if (state.user && state.taskLists.length === 0) {
return dispatch("refreshTaskLists");
} else {
return Promise.resolve();
}
},
createTaskList({commit, dispatch}, newList) {
return api.postTaskList(newList)
.then(data => commit("setCurrentTaskList", data))
.then(() => dispatch("refreshTaskLists"))
},
deleteTaskList({dispatch}, taskList) {
return api.deleteTaskList(taskList)
.then(() => dispatch("refreshTaskLists"));
},
createTaskItem({commit, dispatch}, taskItem) {
return api.postTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
commit("appendTaskItem", data);
return data;
});
},
updateTaskItem({commit}, taskItem) {
return api.patchTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
commit("replaceTaskItem", data);
return data;
});
},
deleteTaskItems({commit}, payload) {
return api.deleteTaskItems(payload.taskList.id, payload.taskItems)
.then(() => commit("removeTaskItems", payload));
},
completeTaskItems({commit}, payload) {
return api.completeTaskItems(payload.taskList.id, payload.taskItems, !payload.completed)
.then(() => commit("setTaskItemCompletion", payload));
}
}
});

View File

@ -0,0 +1,102 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRoute } from "vue-router";
import api from "../lib/Api";
export const useAppConfigStore = defineStore('appConfig', () => {
const authChecked = ref(false);
const errorObject = ref(null);
const initialLoad = ref(false);
const loadingCount = ref(0);
const loginMessage = ref(null);
const updateAvailable = ref(false);
const userObject = ref(null);
const route = useRoute();
const isLoading = computed(() => {
return loadingCount.value > 0;
});
const isLoggedIn = computed(() => {
return userObject.value !== null;
});
const isAdmin = computed(() => {
return userObject.value?.admin === true;
});
const error = computed({
get: () => errorObject.value,
set: (val) => {
console.log(val);
errorObject.value = val;
}
});
const user = computed({
get: () => userObject.value,
set: (val) => {
userObject.value = val;
authChecked.value = true;
}
});
function setError(value) {
error.value = value;
}
function setLoading(value) {
if (value) {
loadingCount.value = loadingCount.value + 1;
} else {
loadingCount.value = loadingCount.value - 1;
}
}
async function updateCurrentUser() {
user.value = await api.getCurrentUser();
return user.value;
}
function login(authData) {
return api.postLogin(authData.username, authData.password)
.then(data => {
if (data.success) {
user.value = data.user;
loginMessage.value = null;
} else {
user.value = null;
loginMessage.value = data.message;
}
return data;
});
}
function logout() {
return api.getLogout()
.then(() => {
user.value = null;
});
}
return {
authChecked,
error,
initialLoad,
loadingCount,
loginMessage,
updateAvailable,
user,
route,
isAdmin,
isLoading,
isLoggedIn,
login,
logout,
setError,
setLoading,
updateCurrentUser
};
});

View File

@ -0,0 +1,35 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useMediaQueryStore = defineStore('mediaQuery', () => {
// Hard coded values taken directly from Bulma css
const mobileBp = 768;
const desktopBp = 1024;
const widscreenBp = 1216;
const fullHdBp = 1408;
const mediaQueries = {
mobile: `screen and (max-width: ${mobileBp}px)`,
tablet: `screen and (min-width: ${mobileBp + 1}px)`,
tabletOnly: `screen and (min-width: ${mobileBp + 1}px) and (max-width: ${desktopBp - 1}px)`,
touch: `screen and (max-width: ${desktopBp - 1}px)`,
desktop: `screen and (min-width: ${desktopBp}px)`,
desktopOnly: `screen and (min-width: ${desktopBp}px) and (max-width: ${widscreenBp - 1}px)`,
widescreen: `screen and (min-width: ${widscreenBp}px)`,
widescreenOnly: `screen and (min-width: ${widscreenBp}px) and (max-width: ${fullHdBp - 1}px)`,
fullhd: `screen and (min-width: ${fullHdBp}px)`
};
const store = {};
for (let device in mediaQueries) {
const query = window.matchMedia(mediaQueries[device]);
store[device] = ref(query.matches);
query.onchange = (q) => {
store[device].value = q.matches;
};
}
return store;
});

View File

@ -0,0 +1,36 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useNutrientStore = defineStore('nutrient', () => {
const nutrientList = ref({
kcal: { label: "Calories", unit: "kcal" },
protein: { label: "Protein", unit: "g" },
lipids: { label: "Fat", unit: "g" },
carbohydrates: { label: "Carbohydrates", unit: "g" },
water: { label: "Water", unit: "g" },
sugar: { label: "Sugar", unit: "g" },
fiber: { label: "Fiber", unit: "g" },
cholesterol: { label: "Cholesterol", unit: "mg" },
sodium: { label: "Sodium", unit: "mg" },
calcium: { label: "Calcium", unit: "mg" },
iron: { label: "Iron", unit: "mg" },
magnesium: { label: "Magnesium", unit: "mg" },
phosphorus: { label: "Phosphorus", unit: "mg" },
potassium: { label: "Potassium", unit: "mg" },
zinc: { label: "Zinc", unit: "mg" },
copper: { label: "Copper", unit: "mg" },
manganese: { label: "Manganese", unit: "mg" },
vit_a: { label: "Vitamin A", unit: "μg" },
vit_b6: { label: "Vitamin B6", unit: "mg" },
vit_b12: { label: "Vitamin B12", unit: "μg" },
vit_c: { label: "Vitamin C", unit: "mg" },
vit_d: { label: "Vitamin D", unit: "μg" },
vit_e: { label: "Vitamin E", unit: "mg" },
vit_k: { label: "Vitamin K", unit: "μg" },
ash: { label: "ash", unit: "g" }
});
return {
nutrientList
};
});

View File

@ -0,0 +1,114 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useAppConfigStore } from "./appConfig";
import api from "../lib/Api";
import { createChannel } from "../lib/ActionCable";
export const useTaskStore = defineStore('task', () => {
const taskLists = ref([]);
const currentTaskList = ref(null);
let taskChannel = null;
function replaceTaskList(list) {
if (taskLists.value) {
const listIdx = taskLists.value.findIndex(l => l.id === list.id);
if (listIdx >= 0) {
taskLists.value.splice(listIdx, 1, list);
}
if (currentTaskList.value && currentTaskList.value.id === list.id) {
currentTaskList.value = list;
}
}
}
function ensureTaskListChannel() {
if (taskChannel === null) {
taskChannel = createChannel(null, "TaskChannel", {
received(data) {
if (data && data.action === 'updated') {
replaceTaskList(data.task_list);
}
}
});
}
}
function refreshTaskLists() {
const cb = function(data) {
taskLists.value = data || [];
let ctl = null;
if (currentTaskList.value) {
ctl = data.find(l => l.id === currentTaskList.value.id);
}
ctl = ctl || data[0] || null;
setCurrentTaskList(ctl);
ensureTaskListChannel();
};
return api.getTaskLists(cb)
}
function ensureTaskLists() {
const appConfig = useAppConfigStore();
if (appConfig.user && taskLists.value.length === 0) {
return refreshTaskLists();
} else {
return Promise.resolve();
}
}
function setCurrentTaskList(taskList) {
currentTaskList.value = taskList || null;
}
async function createTaskList(newList) {
currentTaskList.value = await api.postTaskList(newList);
return refreshTaskLists();
}
async function deleteTaskList(taskList) {
await api.deleteTaskList(taskList);
return refreshTaskLists();
}
function createTaskItem(taskItem) {
return api.postTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
return data;
});
}
function updateTaskItem(taskItem) {
return api.patchTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
return data;
});
}
function deleteTaskItems(payload) {
return api.deleteTaskItems(payload.taskList.id, payload.taskItems);
}
function completeTaskItems(payload) {
return api.completeTaskItems(payload.taskList.id, payload.taskItems, !payload.completed);
}
return {
currentTaskList,
taskLists,
createTaskList,
deleteTaskList,
ensureTaskLists,
refreshTaskLists,
setCurrentTaskList,
createTaskItem,
updateTaskItem,
deleteTaskItems,
completeTaskItems
};
});

View File

@ -1,107 +1,97 @@
@mixin responsive-button-size($size) {
&.is-small-#{$size} {
@include button-small;
}
&.is-medium-#{$size} {
@include button-medium;
}
&.is-large-#{$size} {
@include button-large;
}
}
@use "bulma/sass/utilities" as bulma;
@use "bulma/sass/utilities/controls";
@use "bulma/sass/utilities/mixins";
@mixin responsive-label-size($size) {
&.is-small-#{$size} {
font-size: $size-small;
font-size: bulma.$size-small;
}
&.is-medium-#{$size} {
font-size: $size-medium;
font-size: bulma.$size-medium;
}
&.is-large-#{$size} {
font-size: $size-large;
font-size: bulma.$size-large;
}
}
@mixin responsive-control-size($size) {
&.is-small-#{$size} {
@include control-small;
@include controls.control-small;
}
&.is-medium-#{$size} {
@include control-medium;
@include controls.control-medium;
}
&.is-large-#{$size} {
@include control-large;
@include controls.control-large;
}
}
.button {
@include mobile {
@include responsive-button-size("mobile");
@include mixins.mobile {
@include responsive-control-size("mobile");
}
@include tablet {
@include responsive-button-size("tablet");
@include mixins.tablet {
@include responsive-control-size("tablet");
}
@include desktop {
@include responsive-button-size("desktop");
@include mixins.desktop {
@include responsive-control-size("desktop");
}
@include widescreen {
@include responsive-button-size("widescreen");
@include mixins.widescreen {
@include responsive-control-size("widescreen");
}
@include fullhd {
@include responsive-button-size("fullhd");
@include mixins.fullhd {
@include responsive-control-size("fullhd");
}
}
.label {
@include mobile {
@include mixins.mobile {
@include responsive-label-size("mobile");
}
@include tablet {
@include mixins.tablet {
@include responsive-label-size("tablet");
}
@include desktop {
@include mixins.desktop {
@include responsive-label-size("desktop");
}
@include widescreen {
@include mixins.widescreen {
@include responsive-label-size("widescreen");
}
@include fullhd {
@include mixins.fullhd {
@include responsive-label-size("fullhd");
}
}
.input, .textarea {
@include mobile {
@include mixins.mobile {
@include responsive-control-size("mobile");
}
@include tablet {
@include mixins.tablet {
@include responsive-control-size("tablet");
}
@include desktop {
@include mixins.desktop {
@include responsive-control-size("desktop");
}
@include widescreen {
@include mixins.widescreen {
@include responsive-control-size("widescreen");
}
@include fullhd {
@include mixins.fullhd {
@include responsive-control-size("fullhd");
}
}

View File

@ -1,35 +0,0 @@
// coolors.co pallet
$coolors-dark: rgba(29, 30, 24, 1);
$coolors-blue: rgba(67, 127, 151, 1);
$coolors-green: rgba(121, 167, 54, 1);
$coolors-red: #ab4c34;
$coolors-yellow: rgba(240, 162, 2, 1);
//$family-sans-serif: "Verdana", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default
$family-serif: Georgia, "Times New Roman", Times, serif;
// Bluma default overrides
//$green: #79A736;
//$red: #d4424e;
//$blue: #9c36a7;
$green: $coolors-green;
$blue: $coolors-blue;
$red: $coolors-red;
$yellow: $coolors-yellow;
$dark: $coolors-dark;
$primary: $green;
$family-primary: $family-serif;
$modal-content-width: 750px;
// Make all Bulma variables and functions available
@import "~bulma/sass/utilities/initial-variables";
@import "~bulma/sass/utilities/functions";
@import "~bulma/sass/utilities/derived-variables";
//$dropdown-item-hover-background-color: $white-ter;

View File

@ -1,5 +1,9 @@
@include until($desktop) {
@use "bulma/sass/utilities" as bulma;
@use "bulma/sass/utilities/mixins";
@include mixins.until(bulma.$desktop) {
.modal.is-wide {
.modal-content, .modal-card {
margin: 0 20px;
@ -8,7 +12,7 @@
}
}
@include from($desktop) {
@include mixins.from(bulma.$desktop) {
.modal.is-wide {
.modal-content, .modal-card {
margin: 0 auto;

View File

@ -1,18 +1,22 @@
@import "./variables";
// coolors.co pallet
$coolors-dark: rgba(29, 30, 24, 1);
$coolors-blue: rgba(67, 127, 151, 1);
$coolors-green: rgba(121, 167, 54, 1);
$coolors-red: #ab4c34;
$coolors-yellow: rgba(240, 162, 2, 1);
@import "~bulma/sass/utilities/_all";
@import "~bulma/sass/base/_all";
@import "~bulma/sass/components/dropdown";
@import "~bulma/sass/components/navbar";
@import "~bulma/sass/components/level";
@import "~bulma/sass/components/message";
@import "~bulma/sass/components/modal";
@import "~bulma/sass/components/pagination";
@import "~bulma/sass/components/panel";
@import "~bulma/sass/elements/_all";
@import "~bulma/sass/form/_all";
@import "~bulma/sass/grid/columns";
@import "~bulma/sass/layout/section";
$family-serif: Georgia, "Times New Roman", Times, serif;
@use "bulma/versions/bulma-no-dark-mode" as bulma with (
$family-primary: $family-serif,
$green: $coolors-green,
$blue: $coolors-blue,
$red: $coolors-red,
$yellow: $coolors-yellow,
$dark: $coolors-dark,
$primary: $coolors-green,
$modal-content-width: 750px
);
@import "./responsive_controls";
@import "./wide_modal";
@ -28,7 +32,7 @@ body {
}
body {
background-color: $grey-dark;
background-color: bulma.$grey-dark;
padding-bottom: 2rem;
}
@ -37,13 +41,13 @@ body {
.container {
padding: 1rem;
background-color: $white;
background-color: bulma.$white;
min-height: 75vh;
}
}
.title, .subtitle, .navbar, .button, .pagination, .modal-card-title, th {
font-family: $family-sans-serif;
font-family: bulma.$family-sans-serif;
}
.pagination:not(:last-child) {

View File

@ -91,15 +91,15 @@ class Food < Ingredient
:vit_e,
:vit_d,
:vit_k,
:cholesterol
:cholesterol,
[:lipids, :lipid],
[:density, :density_best_guess]
]
copy_fields.each do |f|
self.send("#{f}=".to_sym, food.send(f.to_sym))
to, from = f.is_a?(Array) ? f : [f, f]
self.send("#{to}=".to_sym, food.send(from.to_sym))
end
self.lipids = food.lipid
self.density = food.density_best_guess
end
end

View File

@ -1,7 +1,7 @@
class TaskItem < ApplicationRecord
include DefaultValues
belongs_to :task_list, touch: true
belongs_to :task_list, touch: true, inverse_of: :task_items
validates :name, presence: true

View File

@ -1,7 +1,7 @@
class TaskList < ApplicationRecord
belongs_to :user
has_many :task_items, dependent: :delete_all
has_many :task_items, dependent: :delete_all, inverse_of: :task_list
validates :name,
presence: true,
@ -13,22 +13,22 @@ class TaskList < ApplicationRecord
if recurse_depth > 10
raise "This shouldn't be. Did you make a recipe loop?"
end
recipe.recipe_ingredients.each do |ri|
if ri.ingredient.is_a?(Recipe)
add_recipe_ingredients(ri.ingredient, recurse_depth + 1)
else
item = self.task_items.detect { |i| i.name.downcase == ri.name.downcase } || TaskItem.new(name: ri.name, task_list: self)
item = self.task_items.detect { |i| i.name.downcase == ri.name.downcase } || task_items.build(name: ri.name)
quantity_str = [ri.quantity, ri.units].delete_if { |i| i.blank? }.join(' ')
if item.quantity.blank?
item.quantity = quantity_str
else
item.quantity += (', ' + quantity_str)
end
item.save
end
end
end
end

View File

@ -5,7 +5,7 @@ class ApplicationSerializer
def initialize(items, serializer, opts = {})
super(items, opts)
@collection_name = 'list'
@collection_name = opts[:collection_name]
@serializer = serializer
end
@ -14,10 +14,10 @@ class ApplicationSerializer
if @collection_name && item.respond_to?(:total_pages)
{
totalCount: item.total_count,
totalPages: item.total_pages,
currentPage: item.current_page,
pageSize: item.limit_value,
total_count: item.total_count,
total_pages: item.total_pages,
current_page: item.current_page,
page_size: item.limit_value,
@collection_name.to_sym => list
}
else

Some files were not shown because too many files have changed in this diff Show More