Compare commits

..

No commits in common. "659d7405b243b2a644a5d6e6d1fe49c5805ed858" and "b311a7d7e8731a9acbcb045228ae8cd6c8c63389" have entirely different histories.

145 changed files with 11493 additions and 10729 deletions

1
.browserslistrc Normal file
View File

@ -0,0 +1 @@
> 1%, not ie <= 12

View File

@ -5,5 +5,3 @@ tmp/*.*
public/assets public/assets
public/packs public/packs
node_modules/ node_modules/
.yarn
.pnp.*

11
.gitignore vendored
View File

@ -9,7 +9,7 @@
# Ignore the default SQLite database. # Ignore the default SQLite database.
/db/*.sqlite3 /db/*.sqlite3
/db/*.sqlite3* /db/*.sqlite3-journal
# Ignore all logfiles and tempfiles. # Ignore all logfiles and tempfiles.
/log/* /log/*
@ -28,12 +28,3 @@
yarn-debug.log* yarn-debug.log*
.yarn-integrity .yarn-integrity
/dist /dist
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
.yarn
.pnp.*

View File

@ -1 +1 @@
3.3.5 3.0.1

View File

@ -1,13 +1,17 @@
FROM ruby:3.3.5-bookworm FROM ruby:3.0.1-buster
RUN curl -sL https://deb.nodesource.com/setup_lts.x | bash - && \ 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 update && apt-get dist-upgrade -y && \
apt-get install -y \ apt-get install -y \
nodejs \ nodejs \
yarn \
nginx && \ nginx && \
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* 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 RUN gem update --system && gem install bundler
# Install nginx config files # Install nginx config files
RUN rm /etc/nginx/sites-enabled/default RUN rm /etc/nginx/sites-enabled/default
@ -28,12 +32,12 @@ COPY Gemfile* ./
RUN bundle install RUN bundle install
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --immutable RUN yarn install --production=true --frozen-lockfile
COPY . . COPY . .
# Compile assets # Compile assets
RUN env RAILS_ENV=production bundle exec rails shakapacker:clobber shakapacker:compile RUN env RAILS_ENV=production bundle exec rails webpacker:clobber webpacker:compile
ENV PORT=80 ENV PORT=80
EXPOSE 80 EXPOSE 80

32
Gemfile
View File

@ -1,30 +1,30 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gem 'rails', '7.2.1' gem 'rails', '6.1.4.4'
gem 'pg', '~> 1.5.8' gem 'pg', '~> 1.2.3'
gem 'shakapacker', '8.0.2' gem 'webpacker', '5.4.3'
gem 'bootsnap', '>= 1.1.0', require: false gem 'bootsnap', '>= 1.1.0', require: false
gem 'oj', '~> 3.16.6' gem 'oj', '~> 3.11.5'
gem 'csv', '~> 3.3'
gem 'kaminari', '~> 1.2.2' gem 'kaminari', '~> 1.2.1'
gem 'unitwise', '~> 2.3.0' gem 'unitwise', '~> 2.2.0'
gem 'redcarpet', '~> 3.6.0' gem 'redcarpet', '~> 3.5.1'
gem 'dalli', '~> 3.2.8' gem 'dalli', '~> 2.7.11'
gem 'puma', '~> 6.4' gem 'puma', '~> 5.3'
gem 'bcrypt', '~> 3.1.18' gem 'bcrypt', '~> 3.1.16'
gem 'tzinfo-data' gem 'tzinfo-data'
group :development, :test do group :development, :test do
gem 'sqlite3', '~> 2.1.0' gem 'sqlite3', '~> 1.4.2'
gem 'guard', '~> 2.18.0' gem 'guard', '~> 2.16.2'
gem 'guard-rspec', require: false gem 'guard-rspec', require: false
gem 'rspec-rails', '~> 7.0.1' gem 'rspec-rails', '~> 5.0.1'
gem 'rails-controller-testing' gem 'rails-controller-testing'
gem 'factory_bot_rails', '~> 6.4.3' gem 'factory_bot_rails', '~> 6.2.0'
gem 'database_cleaner', '~> 2.0.2' gem 'database_cleaner', '~> 2.0.1'
end end

View File

@ -1,114 +1,97 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.1) actioncable (6.1.4.4)
actionpack (= 7.2.1) actionpack (= 6.1.4.4)
activesupport (= 7.2.1) activesupport (= 6.1.4.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) actionmailbox (6.1.4.4)
actionmailbox (7.2.1) actionpack (= 6.1.4.4)
actionpack (= 7.2.1) activejob (= 6.1.4.4)
activejob (= 7.2.1) activerecord (= 6.1.4.4)
activerecord (= 7.2.1) activestorage (= 6.1.4.4)
activestorage (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1) mail (>= 2.7.1)
mail (>= 2.8.0) actionmailer (6.1.4.4)
actionmailer (7.2.1) actionpack (= 6.1.4.4)
actionpack (= 7.2.1) actionview (= 6.1.4.4)
actionview (= 7.2.1) activejob (= 6.1.4.4)
activejob (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1) mail (~> 2.5, >= 2.5.4)
mail (>= 2.8.0) rails-dom-testing (~> 2.0)
rails-dom-testing (~> 2.2) actionpack (6.1.4.4)
actionpack (7.2.1) actionview (= 6.1.4.4)
actionview (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1) rack (~> 2.0, >= 2.0.9)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.0, >= 1.2.0)
useragent (~> 0.16) actiontext (6.1.4.4)
actiontext (7.2.1) actionpack (= 6.1.4.4)
actionpack (= 7.2.1) activerecord (= 6.1.4.4)
activerecord (= 7.2.1) activestorage (= 6.1.4.4)
activestorage (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.1) actionview (6.1.4.4)
activesupport (= 7.2.1) activesupport (= 6.1.4.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.4)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.2.1) activejob (6.1.4.4)
activesupport (= 7.2.1) activesupport (= 6.1.4.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.1) activemodel (6.1.4.4)
activesupport (= 7.2.1) activesupport (= 6.1.4.4)
activerecord (7.2.1) activerecord (6.1.4.4)
activemodel (= 7.2.1) activemodel (= 6.1.4.4)
activesupport (= 7.2.1) activesupport (= 6.1.4.4)
timeout (>= 0.4.0) activestorage (6.1.4.4)
activestorage (7.2.1) actionpack (= 6.1.4.4)
actionpack (= 7.2.1) activejob (= 6.1.4.4)
activejob (= 7.2.1) activerecord (= 6.1.4.4)
activerecord (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1) marcel (~> 1.0.0)
marcel (~> 1.0) mini_mime (>= 1.1.0)
activesupport (7.2.1) activesupport (6.1.4.4)
base64 concurrent-ruby (~> 1.0, >= 1.0.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) tzinfo (~> 2.0)
tzinfo (~> 2.0, >= 2.0.5) zeitwerk (~> 2.3)
base64 (0.2.0) bcrypt (3.1.16)
bcrypt (3.1.20) bootsnap (1.10.3)
bigdecimal (3.1.8)
bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.3.0) builder (3.2.4)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.3.4) concurrent-ruby (1.1.9)
connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
csv (3.3.0) dalli (2.7.11)
dalli (3.2.8) database_cleaner (2.0.1)
database_cleaner (2.0.2) database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (2.0.1)
database_cleaner-active_record (2.2.0)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.4) diff-lcs (1.5.0)
diff-lcs (1.5.1) erubi (1.10.0)
drb (2.2.1) factory_bot (6.2.0)
erubi (1.13.0)
factory_bot (6.5.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.3) factory_bot_rails (6.2.0)
factory_bot (~> 6.4) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
ffi (1.17.0) ffi (1.15.5)
formatador (1.1.0) formatador (1.1.0)
globalid (1.2.1) globalid (1.0.0)
activesupport (>= 6.1) activesupport (>= 5.0)
guard (2.18.1) guard (2.16.2)
formatador (>= 0.2.4) formatador (>= 0.2.4)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
lumberjack (>= 1.0.12, < 2.0) lumberjack (>= 1.0.12, < 2.0)
nenv (~> 0.1) nenv (~> 0.1)
notiffany (~> 0.0) notiffany (~> 0.0)
pry (>= 0.13.0) pry (>= 0.9.12)
shellany (~> 0.0) shellany (~> 0.0)
thor (>= 0.18.1) thor (>= 0.18.1)
guard-compat (1.2.1) guard-compat (1.2.1)
@ -116,12 +99,8 @@ GEM
guard (~> 2.1) guard (~> 2.1)
guard-compat (~> 1.1) guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0) rspec (>= 2.99.0, < 4.0)
i18n (1.14.6) i18n (1.9.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.7.2)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@ -135,188 +114,156 @@ GEM
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
liner (0.2.4) liner (0.2.4)
listen (3.9.0) listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1) loofah (2.13.0)
loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.5.9)
lumberjack (1.2.10) lumberjack (1.2.8)
mail (2.8.1) mail (2.7.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap marcel (1.0.2)
net-pop
net-smtp
marcel (1.0.4)
memoizable (0.4.2) memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
method_source (1.1.0) method_source (1.0.0)
mini_mime (1.1.5) mini_mime (1.1.2)
mini_portile2 (2.8.7) mini_portile2 (2.7.1)
minitest (5.25.1) minitest (5.15.0)
msgpack (1.7.2) msgpack (1.4.4)
nenv (0.3.0) nenv (0.3.0)
net-imap (0.4.16) nio4r (2.5.8)
date nokogiri (1.13.1)
net-protocol mini_portile2 (~> 2.7.0)
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) racc (~> 1.4)
notiffany (0.1.3) notiffany (0.1.3)
nenv (~> 0.1) nenv (~> 0.1)
shellany (~> 0.0) shellany (~> 0.0)
oj (3.16.6) oj (3.11.8)
bigdecimal (>= 3.0) parslet (1.8.2)
ostruct (>= 0.2) pg (1.2.3)
ostruct (0.6.0) pry (0.14.1)
package_json (0.1.0)
parslet (2.0.0)
pg (1.5.8)
pry (0.14.2)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
psych (5.1.2) puma (5.6.1)
stringio
puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.6.0)
rack (3.1.7) rack (2.2.3)
rack-proxy (0.7.7) rack-proxy (0.7.2)
rack rack
rack-session (2.0.0) rack-test (1.1.0)
rack (>= 3.0.0) rack (>= 1.0, < 3)
rack-test (2.1.0) rails (6.1.4.4)
rack (>= 1.3) actioncable (= 6.1.4.4)
rackup (2.1.0) actionmailbox (= 6.1.4.4)
rack (>= 3) actionmailer (= 6.1.4.4)
webrick (~> 1.8) actionpack (= 6.1.4.4)
rails (7.2.1) actiontext (= 6.1.4.4)
actioncable (= 7.2.1) actionview (= 6.1.4.4)
actionmailbox (= 7.2.1) activejob (= 6.1.4.4)
actionmailer (= 7.2.1) activemodel (= 6.1.4.4)
actionpack (= 7.2.1) activerecord (= 6.1.4.4)
actiontext (= 7.2.1) activestorage (= 6.1.4.4)
actionview (= 7.2.1) activesupport (= 6.1.4.4)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.1) railties (= 6.1.4.4)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0) rails-dom-testing (2.0.3)
activesupport (>= 5.0.0) activesupport (>= 4.2.0)
minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.4.2)
loofah (~> 2.21) loofah (~> 2.3)
nokogiri (~> 1.14) railties (6.1.4.4)
railties (7.2.1) actionpack (= 6.1.4.4)
actionpack (= 7.2.1) activesupport (= 6.1.4.4)
activesupport (= 7.2.1) method_source
irb (~> 1.13) rake (>= 0.13)
rackup (>= 1.0.0) thor (~> 1.0)
rake (>= 12.2) rake (13.0.6)
thor (~> 1.0, >= 1.2.2) rb-fsevent (0.11.0)
zeitwerk (~> 2.6) rb-inotify (0.10.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
rdoc (6.7.0) redcarpet (3.5.1)
psych (>= 4.0.0) rspec (3.10.0)
redcarpet (3.6.0) rspec-core (~> 3.10.0)
reline (0.5.10) rspec-expectations (~> 3.10.0)
io-console (~> 0.5) rspec-mocks (~> 3.10.0)
rspec (3.13.0) rspec-core (3.10.2)
rspec-core (~> 3.13.0) rspec-support (~> 3.10.0)
rspec-expectations (~> 3.13.0) rspec-expectations (3.10.2)
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) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.10.0)
rspec-mocks (3.13.1) rspec-mocks (3.10.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.10.0)
rspec-rails (7.0.1) rspec-rails (5.0.3)
actionpack (>= 7.0) actionpack (>= 5.2)
activesupport (>= 7.0) activesupport (>= 5.2)
railties (>= 7.0) railties (>= 5.2)
rspec-core (~> 3.13) rspec-core (~> 3.10)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.10)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.10)
rspec-support (~> 3.13) rspec-support (~> 3.10)
rspec-support (3.13.1) rspec-support (3.10.3)
securerandom (0.3.1) semantic_range (3.0.0)
semantic_range (3.0.0) shellany (0.0.1)
shakapacker (8.0.2) signed_multiset (0.2.1)
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
thor (1.2.1)
thread_safe (0.3.6)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.5)
tzinfo (>= 1.0.0)
unitwise (2.2.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 1.5)
signed_multiset (~> 0.2)
webpacker (5.4.3)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
shellany (0.0.1) websocket-driver (0.7.5)
signed_multiset (0.2.1)
sqlite3 (2.1.0)
mini_portile2 (~> 2.8.0)
stringio (3.1.1)
thor (1.3.2)
thread_safe (0.3.6)
timeout (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
unitwise (2.3.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 2.0)
signed_multiset (~> 0.2)
useragent (0.16.10)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.6.18) zeitwerk (2.5.4)
PLATFORMS PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
bcrypt (~> 3.1.18) bcrypt (~> 3.1.16)
bootsnap (>= 1.1.0) bootsnap (>= 1.1.0)
csv (~> 3.3) dalli (~> 2.7.11)
dalli (~> 3.2.8) database_cleaner (~> 2.0.1)
database_cleaner (~> 2.0.2) factory_bot_rails (~> 6.2.0)
factory_bot_rails (~> 6.4.3) guard (~> 2.16.2)
guard (~> 2.18.0)
guard-rspec guard-rspec
kaminari (~> 1.2.2) kaminari (~> 1.2.1)
oj (~> 3.16.6) oj (~> 3.11.5)
pg (~> 1.5.8) pg (~> 1.2.3)
puma (~> 6.4) puma (~> 5.3)
rails (= 7.2.1) rails (= 6.1.4.4)
rails-controller-testing rails-controller-testing
redcarpet (~> 3.6.0) redcarpet (~> 3.5.1)
rspec-rails (~> 7.0.1) rspec-rails (~> 5.0.1)
shakapacker (= 8.0.2) sqlite3 (~> 1.4.2)
sqlite3 (~> 2.1.0)
tzinfo-data tzinfo-data
unitwise (~> 2.3.0) unitwise (~> 2.2.0)
webpacker (= 5.4.3)
BUNDLED WITH BUNDLED WITH
2.5.20 2.2.17

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception protect_from_forgery with: :exception
def verified_request? def verified_request?
if request.media_type == "application/json" if request.content_type == "application/json"
true true
else else
super() super()
@ -42,19 +42,9 @@ class ApplicationController < ActionController::Base
if owner if owner
yield if block_given? yield if block_given?
else else
respond_to do |format|
format.html do
flash[:warning] = "Operation Not Permitted" flash[:warning] = "Operation Not Permitted"
redirect_to root_path redirect_to root_path
end end
format.json do
render json: { error: "Operation Not Permitted" }, status: current_user.nil? ? :unauthorized : :forbidden
end
end
end
end end
def current_user def current_user

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="dropdown" class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}"> <div class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<slot name="button"> <slot name="button">
<button type="button" class="button" :class="buttonClass" @click="toggle"> <button type="button" class="button" :class="buttonClass" @click="toggle">
@ -19,13 +19,10 @@
</div> </div>
</template> </template>
<script setup> <script>
import { useTemplateRef } from "vue"; export default {
import { onClickOutside } from '@vueuse/core' props: {
const emit = defineEmits(["close", "open"]);
const props = defineProps({
open: { open: {
required: false, required: false,
type: Boolean, type: Boolean,
@ -48,34 +45,41 @@ const props = defineProps({
required: false, required: false,
default: "" default: ""
} }
}); },
const dropdownElement = useTemplateRef("dropdown"); methods: {
toggle() {
onClickOutside(dropdownElement, event => handleOutsideClick(event)) if (this.open) {
this.triggerClose();
function toggle() {
if (props.open) {
triggerClose();
} else { } else {
triggerOpen(); this.triggerOpen();
}
} }
},
function triggerOpen() { triggerOpen() {
emit("open"); this.$emit("open");
} },
function triggerClose() { triggerClose() {
emit("close"); this.$emit("close");
} },
function handleOutsideClick(evt) { handleOutsideClick(evt) {
if (props.open) { if (this.open) {
if (!dropdownElement.value.contains(evt.target)) { if (!this.$el.contains(evt.target)) {
triggerClose(); this.triggerClose();
} }
} }
} }
},
mounted() {
document.addEventListener("click", this.handleOutsideClick);
},
beforeDestroy() {
document.removeEventListener("click", this.handleOutsideClick);
}
}
</script> </script>

View File

@ -9,28 +9,33 @@
</transition> </transition>
</template> </template>
<script setup> <script>
import TWEEN from '@tweenjs/tween.js'; import TWEEN from '@tweenjs/tween.js';
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
const props = defineProps({ export default {
props: {
expandTime: { expandTime: {
type: Number, type: Number,
default: 250 default: 250
} }
}); },
let animation = null; data() {
return {
function cancel () { animation: null
if (animation) {
animation.stop();
animation = null;
}
} }
},
function enter(element, done) { methods: {
cancel () {
if (this.animation) {
this.animation.stop();
this.animation = null;
}
},
enter(element, done) {
const width = parseInt(getComputedStyle(element).width); const width = parseInt(getComputedStyle(element).width);
const paddingTop = parseInt(getComputedStyle(element).paddingTop); const paddingTop = parseInt(getComputedStyle(element).paddingTop);
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom); const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
@ -48,15 +53,15 @@
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
element.style.height = 0; element.style.height = 0;
animation = new TWEEN.Tween({height: 0, paddingTop: 0, paddingBottom: 0}) this.animation = new TWEEN.Tween({height: 0, paddingTop: 0, paddingBottom: 0})
.to({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}, props.expandTime) .to({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}, this.expandTime)
.onUpdate(obj => { .onUpdate(obj => {
element.style.height = obj.height + "px"; element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px"; element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px"; element.style.paddingTop = obj.paddingTop + "px";
}) })
.onComplete(() => { .onComplete(() => {
animation = null; this.animation = null;
element.removeAttribute('style'); element.removeAttribute('style');
element.style.opacity = 0.99; element.style.opacity = 0.99;
setTimeout(() => { setTimeout(() => {
@ -65,32 +70,32 @@
}, 1000); }, 1000);
done(); done();
}) })
.group(useGlobalTweenGroup())
.start(); .start();
} },
function leave(element, done) { leave(element, done) {
const height = parseInt(getComputedStyle(element).height); const height = parseInt(getComputedStyle(element).height);
const paddingTop = parseInt(getComputedStyle(element).paddingTop); const paddingTop = parseInt(getComputedStyle(element).paddingTop);
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom); const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
animation = new TWEEN.Tween({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}) this.animation = new TWEEN.Tween({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom})
.to({height: 0, paddingTop: 0, paddingBottom: 0}, props.expandTime) .to({height: 0, paddingTop: 0, paddingBottom: 0}, this.expandTime)
.onUpdate(obj => { .onUpdate(obj => {
element.style.height = obj.height + "px"; element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px"; element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px"; element.style.paddingTop = obj.paddingTop + "px";
}) })
.onComplete(() => { .onComplete(() => {
animation = null; this.animation = null;
element.removeAttribute('style'); element.removeAttribute('style');
done(); done();
}) })
.group(useGlobalTweenGroup())
.start(); .start();
} }
}
}
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@
<div class="field"> <div class="field">
<label v-if="label.length" class="label is-small-mobile">{{ label }}</label> <label v-if="label.length" class="label is-small-mobile">{{ label }}</label>
<div :class="controlClasses"> <div :class="controlClasses">
<textarea v-if="isTextarea" :class="inputClasses" v-model="model" :disabled="disabled"></textarea> <textarea v-if="isTextarea" :class="inputClasses" :value="value" @input="input" :disabled="disabled"></textarea>
<input v-else :type="type" :class="inputClasses" v-model="model" :disabled="disabled"> <input v-else :type="type" :class="inputClasses" :value="value" @input="input" :disabled="disabled">
<app-icon class="is-right" icon="warning" v-if="validationError !== null"></app-icon> <app-icon class="is-right" icon="warning" v-if="validationError !== null"></app-icon>
</div> </div>
<p v-if="helpMessage !== null" :class="helpClasses"> <p v-if="helpMessage !== null" :class="helpClasses">
@ -12,17 +12,16 @@
</div> </div>
</template> </template>
<script setup> <script>
import { computed } from "vue"; export default {
props: {
const props = defineProps({
label: { label: {
required: false, required: false,
type: String, type: String,
default: "" default: ""
}, },
modelValue: { value: {
required: false, required: false,
type: [String, Number], type: [String, Number],
default: "" default: ""
@ -41,38 +40,53 @@ const props = defineProps({
type: String, type: String,
default: null default: null
} }
}); },
const model = defineModel({ computed: {
type: [String, Number], isTextarea() {
default: "" return this.type === "textarea";
}); },
const isTextarea = computed(() => props.type === "textarea"); controlClasses() {
const controlClasses = computed(() => [ return [
"control", "control",
{ {
"has-icons-right": props.validationError !== null "has-icons-right": this.validationError !== null
} }
]); ]
},
const inputClasses = computed(() =>[ inputClasses() {
return [
"is-small-mobile", "is-small-mobile",
{ {
"textarea": isTextarea.value, "textarea": this.isTextarea,
"input": !isTextarea.value, "input": !this.isTextarea,
"is-danger": props.validationError !== null "is-danger": this.validationError !== null
} }
]); ]
},
const helpMessage = computed(() => props.validationError); helpMessage() {
const helpClasses = computed(() => [ return this.validationError;
},
helpClasses() {
return [
"help", "help",
{ {
"is-danger": props.validationError !== null "is-danger": this.validationError !== null
} }
]); ];
}
},
methods: {
input(evt) {
this.$emit("input", evt.target.value);
}
}
}
</script> </script>

View File

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

View File

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

View File

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

View File

@ -31,11 +31,12 @@
</template> </template>
<script setup> <script>
import RecipeEdit from "./RecipeEdit"; import RecipeEdit from "./RecipeEdit";
const props = defineProps({ export default {
props: {
log: { log: {
required: true, required: true,
type: Object type: Object
@ -45,7 +46,12 @@
type: Object, type: Object,
default: {} default: {}
}, },
}); },
components: {
RecipeEdit
}
}
</script> </script>

View File

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

View File

@ -19,26 +19,36 @@
</div> </div>
</template> </template>
<script setup> <script>
import { computed } from "vue"; export default {
props: {
const emit = defineEmits(["save", "cancel"]);
const props = defineProps({
note: { note: {
required: true, required: true,
type: Object type: Object
} }
}); },
const canSave = computed(() => props.note?.content?.length); data() {
return {
};
},
function save() { computed: {
emit("save", props.note); canSave() {
return this.note && this.note.content && this.note.content.length;
} }
},
function cancel() { methods: {
emit("cancel"); save() {
this.$emit("save", this.note);
},
cancel() {
this.$emit("cancel");
}
}
} }
</script> </script>

View File

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

View File

@ -12,22 +12,18 @@
</div> </div>
<div class="column is-half"> <div class="column is-half">
<table class="table is-bordered is-narrow is-size-7"> <table class="table is-bordered is-narrow is-size-7">
<thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Unit</th> <th>Unit</th>
<th>Name</th> <th>Name</th>
<th>Prep</th> <th>Prep</th>
</tr> </tr>
</thead>
<tbody>
<tr v-for="i in bulkIngredientPreview"> <tr v-for="i in bulkIngredientPreview">
<td>{{i.quantity}}</td> <td>{{i.quantity}}</td>
<td>{{i.units}}</td> <td>{{i.units}}</td>
<td>{{i.name}}</td> <td>{{i.name}}</td>
<td>{{i.preparation}}</td> <td>{{i.preparation}}</td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
@ -43,28 +39,33 @@
</div> </div>
</template> </template>
<script setup> <script>
import { computed, ref } from "vue";
import { useMediaQueryStore } from "../stores/mediaQuery";
import RecipeEditIngredientItem from "./RecipeEditIngredientItem"; import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
const mediaQueryStore = useMediaQueryStore(); import { mapState } from "vuex";
const props = defineProps({ export default {
props: {
ingredients: { ingredients: {
required: true, required: true,
type: Array type: Array
} }
}); },
const isBulkEditing = ref(false); data() {
const bulkEditText = ref(null); return {
const isMobile = computed(() => mediaQueryStore.mobile); isBulkEditing: false,
const visibleIngredients = computed(() => props.ingredients.filter(i => i._destroy !== true)); bulkEditText: null
};
},
const bulkIngredientPreview = computed(() => { computed: {
if (bulkEditText.value === null || bulkEditText.value === "") { ...mapState({
isMobile: state => state.mediaQueries.mobile
}),
bulkIngredientPreview() {
if (this.bulkEditText === null) {
return []; return [];
} }
@ -79,7 +80,7 @@
}; };
const parsed = []; const parsed = [];
const lines = bulkEditText.value.replace("\r", "").split("\n"); const lines = this.bulkEditText.replace("\r", "").split("\n");
for (let line of lines) { for (let line of lines) {
if (line.length === 0) { continue; } if (line.length === 0) { continue; }
@ -96,10 +97,16 @@
} }
return parsed; return parsed;
}); },
function createIngredient() { visibleIngredients() {
const sort_orders = props.ingredients.map(i => i.sort_order); return this.ingredients.filter(i => i._destroy !== true);
}
},
methods: {
createIngredient() {
const sort_orders = this.ingredients.map(i => i.sort_order);
sort_orders.push(0); sort_orders.push(0);
const next_sort_order = Math.max(...sort_orders) + 5; const next_sort_order = Math.max(...sort_orders) + 5;
@ -112,27 +119,27 @@
ingredient_id: null, ingredient_id: null,
sort_order: next_sort_order sort_order: next_sort_order
}; };
} },
function addIngredient() { addIngredient() {
props.ingredients.push(createIngredient()); this.ingredients.push(this.createIngredient());
} },
function deleteFood(food) { deleteFood(food) {
if (food.id) { if (food.id) {
food._destroy = true; food._destroy = true;
} else { } else {
const idx = props.ingredients.findIndex(i => i === food); const idx = this.ingredients.findIndex(i => i === food);
props.ingredients.splice(idx, 1); this.ingredients.splice(idx, 1);
}
} }
},
function bulkEditIngredients() { bulkEditIngredients() {
isBulkEditing.value = true; this.isBulkEditing = true;
let text = []; let text = [];
for (let item of visibleIngredients.value) { for (let item of this.visibleIngredients) {
text.push( text.push(
item.quantity + " " + item.quantity + " " +
(item.units || "-") + " " + (item.units || "-") + " " +
@ -142,16 +149,16 @@
); );
} }
bulkEditText.value = text.join("\n"); this.bulkEditText = text.join("\n");
} },
function cancelBulkEditing() { cancelBulkEditing() {
isBulkEditing.value = false; this.isBulkEditing = false;
} },
function saveBulkEditing() { saveBulkEditing() {
const parsed = bulkIngredientPreview.value.filter(i => i !== null); const parsed = this.bulkIngredientPreview.filter(i => i !== null);
const existing = [...props.ingredients]; const existing = [...this.ingredients];
const newList = []; const newList = [];
for (let parsedIngredient of parsed) { for (let parsedIngredient of parsed) {
@ -170,7 +177,7 @@
} }
if (newIngredient === null) { if (newIngredient === null) {
newIngredient = createIngredient(); newIngredient = this.createIngredient();
} }
newIngredient.quantity = parsedIngredient.quantity; newIngredient.quantity = parsedIngredient.quantity;
@ -184,14 +191,20 @@
newList.push({id: oldExisting.id, _destroy: true}); newList.push({id: oldExisting.id, _destroy: true});
} }
props.ingredients.splice(0); this.ingredients.splice(0);
let sortIdx = 0; let sortIdx = 0;
for (let n of newList) { for (let n of newList) {
n.sort_order = sortIdx++; n.sort_order = sortIdx++;
props.ingredients.push(n); this.ingredients.push(n);
} }
isBulkEditing.value = false; this.isBulkEditing = false;
}
},
components: {
RecipeEditIngredientItem
}
} }
</script> </script>

View File

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

View File

@ -38,7 +38,7 @@
Ingredients Ingredients
<button class="button is-small is-primary" type="button" @click="showConvertDialog = true">Convert</button> <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"> <app-dropdown :open="addToTasksMenuOpen" label="Add to list" button-class="is-small is-primary" @open="addToTasksMenuOpen = true" @close="addToTasksMenuOpen = false">
<button class="button primary" v-for="tl in taskStore.taskLists" :key="tl.id" @click="addRecipeToList(tl)"> <button class="button primary" v-for="tl in taskLists" :key="tl.id" @click="addRecipeToList(tl)">
{{tl.name}} {{tl.name}}
</button> </button>
</app-dropdown> </app-dropdown>
@ -65,18 +65,14 @@
<div class="message-header" @click="showNutrition = !showNutrition">Nutrition Data</div> <div class="message-header" @click="showNutrition = !showNutrition">Nutrition Data</div>
<div class="message-body" v-show="showNutrition"> <div class="message-body" v-show="showNutrition">
<table class="table"> <table class="table">
<thead>
<tr> <tr>
<th>Item</th> <th>Item</th>
<th>Value</th> <th>Value</th>
</tr> </tr>
</thead>
<tbody>
<tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name"> <tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name">
<td>{{nutrient.label}}</td> <td>{{nutrient.label}}</td>
<td>{{ roundValue(nutrient.value) }}</td> <td>{{ roundValue(nutrient.value) }}</td>
</tr> </tr>
</tbody>
</table> </table>
<h3 class="title is-5">Nutrition Calculation Warnings</h3> <h3 class="title is-5">Nutrition Calculation Warnings</h3>
@ -150,32 +146,30 @@
</div> </div>
</template> </template>
<script setup> <script>
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import api from "../lib/Api"; import api from "../lib/Api";
import { useTaskStore } from "../stores/task"; import { mapActions, mapMutations, mapState } from "vuex";
const taskStore = useTaskStore(); export default {
const router = useRouter(); props: {
const props = defineProps({
recipe: { recipe: {
required: true, required: true,
type: Object type: Object
} }
}); },
const showNutrition = ref(false); data() {
const showConvertDialog = ref(false); return {
const addToTasksMenuOpen = ref(false); showNutrition: false,
showConvertDialog: false,
addToTasksMenuOpen: false,
const scaleValue = ref('1'); scaleValue: '1',
const systemConvertValue = ref(''); systemConvertValue: "",
const unitConvertValue = ref(''); unitConvertValue: "",
const scaleOptions = [ scaleOptions: [
'1/4', '1/4',
'1/3', '1/3',
'1/2', '1/2',
@ -186,60 +180,87 @@
'2', '2',
'3', '3',
'4' '4'
]; ]
};
},
const timeDisplay = computed(() => { computed: {
let a = formatMinutes(props.recipe.active_time); ...mapState([
const t = formatMinutes(props.recipe.total_time); 'taskLists'
]),
timeDisplay() {
let a = this.formatMinutes(this.recipe.active_time);
const t = this.formatMinutes(this.recipe.total_time);
if (a) { if (a) {
a = ` (${a} active)`; a = ` (${a} active)`;
} }
return t + a; return t + a;
}); },
const sourceUrl = computed(() => { sourceUrl() {
try { try {
return new URL(props.recipe.source); return new URL(this.recipe.source);
} catch(err) { } catch(err) {
return null; return null;
} }
}); },
const isSourceUrl = computed(() => sourceUrl.value !== null); isSourceUrl() {
const sourceText = computed(() => isSourceUrl.value ? sourceUrl.value.host : props.recipe.source); return this.sourceUrl !== null;
},
watch(props.recipe, (r) => { sourceText() {
if (this.isSourceUrl) {
return this.sourceUrl.host;
} else {
return this.recipe.source;
}
}
},
watch: {
recipe: {
handler: function(r) {
if (r) { if (r) {
scaleValue.value = r.converted_scale || '1'; this.scaleValue = r.converted_scale || '1';
systemConvertValue.value = r.converted_system; this.systemConvertValue = r.converted_system;
unitConvertValue.value = r.converted_unit; this.unitConvertValue = r.converted_unit;
} }
}, { immediate: true }); },
immediate: true
}
},
onMounted(() => { methods: {
taskStore.ensureTaskLists(); ...mapActions([
}); 'ensureTaskLists'
]),
function addRecipeToList(list) { ...mapMutations([
api.addRecipeToTaskList(list.id, props.recipe.id) 'setCurrentTaskList'
]),
addRecipeToList(list) {
api.addRecipeToTaskList(list.id, this.recipe.id)
.then(() => { .then(() => {
taskStore.setCurrentTaskList(list); this.setCurrentTaskList(list);
router.push({name: 'task_lists'}) this.$router.push({name: 'task_lists'})
}); });
} },
function convert() { convert() {
showConvertDialog.value = false; this.showConvertDialog = false;
router.push({name: 'recipe', query: { scale: scaleValue.value, system: systemConvertValue.value, unit: unitConvertValue.value }}); this.$router.push({name: 'recipe', query: { scale: this.scaleValue, system: this.systemConvertValue, unit: this.unitConvertValue }});
} },
function roundValue(v) { roundValue(v) {
return parseFloat(v).toFixed(2); return parseFloat(v).toFixed(2);
} },
function formatMinutes(min) { formatMinutes(min) {
if (min) { if (min) {
const partUnits = [ const partUnits = [
{unit: "d", minutes: 60 * 24}, {unit: "d", minutes: 60 * 24},
@ -264,6 +285,12 @@
return ""; return "";
} }
} }
},
mounted() {
this.ensureTaskLists();
}
}
</script> </script>

View File

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

View File

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

View File

@ -14,13 +14,10 @@
</template> </template>
<script setup> <script>
import { ref } from "vue"; export default {
props: {
const emit = defineEmits(["select", "delete"]);
const props = defineProps({
taskList: { taskList: {
type: Object, type: Object,
required: true required: true
@ -31,38 +28,44 @@
required: false, required: false,
default: false default: false
} }
}); },
const hovering = ref(false); data() {
const confirmingDelete = ref(false); return {
hovering: false,
confirmingDelete: false
};
},
function selectList() { methods: {
emit("select", props.taskList); selectList() {
this.$emit("select", this.taskList);
},
deleteList() {
this.confirmingDelete = false;
this.$emit("delete", this.taskList);
}
} }
function deleteList() {
confirmingDelete.value = false;
emit("delete", props.taskList);
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "bulma/sass/utilities" as bulma; @import "~styles/variables";
@use 'sass:color';
div.dropdown-item { div.dropdown-item {
cursor: pointer; cursor: pointer;
&.hovered { &.hovered {
color: bulma.$black; color: $black;
background-color: bulma.$background; background-color: $background;
} }
&.is-active { &.is-active {
color: color.invert(bulma.$link); color: $link-invert;
background-color: bulma.$link; background-color: $link;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,20 +9,17 @@
</div> </div>
</template> </template>
<script setup> <script>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import FoodEdit from "./FoodEdit"; import FoodEdit from "./FoodEdit";
import { mapState } from "vuex";
import api from "../lib/Api"; import api from "../lib/Api";
import * as Errors from '../lib/Errors'; import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
const { loadResource } = useLoadResource(); export default {
const router = useRouter(); data() {
return {
const validationErrors = ref({}); food: {
const food = reactive({
name: null, name: null,
notes: null, notes: null,
ndbn: null, ndbn: null,
@ -53,16 +50,26 @@
cholesterol: null, cholesterol: null,
lipids: null, lipids: null,
food_units: [] food_units: []
}); },
validationErrors: {}
}
},
function save() { methods: {
validationErrors.value = {} save() {
loadResource( this.validationErrors = {}
api.postFood(food) this.loadResource(
.then(() => router.push('/foods')) api.postFood(this.food)
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors())) .then(() => this.$router.push('/foods'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
); );
} }
},
components: {
FoodEdit
}
}
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,15 +9,12 @@
</app-modal> </app-modal>
<table class="table"> <table class="table">
<thead>
<tr> <tr>
<th>Note</th> <th>Note</th>
<th>Date</th> <th>Date</th>
<th></th> <th></th>
</tr> </tr>
</thead>
<tbody>
<tr v-for="n in notes" :key="n.id"> <tr v-for="n in notes" :key="n.id">
<td> <td>
{{ n.content }} {{ n.content }}
@ -31,61 +28,69 @@
</button> </button>
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
</template> </template>
<script setup> <script>
import { onBeforeMount, ref } from "vue";
import api from "../lib/Api"; import api from "../lib/Api";
import NoteEdit from "./NoteEdit"; import NoteEdit from "./NoteEdit";
import { useLoadResource } from "../lib/useLoadResource";
const { loadResource } = useLoadResource(); export default {
const notes = ref([]); data() {
const editNote = ref(null); return {
notes: [],
editNote: null
};
},
onBeforeMount(() => { methods: {
refreshList(); refreshList() {
}); this.loadResource(
function refreshList() {
loadResource(
api.getNoteList() api.getNoteList()
.then(data => notes.value = data) .then(data => this.notes = data)
); );
} },
function addNote() { addNote() {
editNote.value = { id: null, content: "" }; this.editNote = { id: null, content: "" };
} },
function saveNote() { saveNote() {
loadResource( this.loadResource(
api.postNote(editNote.value) api.postNote(this.editNote)
.then(() => { .then(() => {
editNote.value = null; this.editNote = null;
return refreshList(); return this.refreshList();
}) })
); );
} },
function cancelNote() { cancelNote() {
editNote.value = null; this.editNote = null;
} },
function deleteNote(n) { deleteNote(n) {
loadResource( this.loadResource(
api.deleteNote(n) api.deleteNote(n)
.then(() => { .then(() => {
return refreshList(); return this.refreshList();
}) })
); );
} }
},
created() {
this.refreshList();
},
components: {
NoteEdit
}
}
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +0,0 @@
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

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

View File

@ -0,0 +1,49 @@
// 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

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

View File

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

View File

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

View File

@ -1,27 +0,0 @@
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,10 +1,18 @@
import '../styles'; import '../styles';
import { createApp } from 'vue'; import "vue-resize/dist/vue-resize";
import { createPinia } from 'pinia';
import Vue from 'vue'
import { sync } from 'vuex-router-sync';
import { swInit } from "../lib/ServiceWorker"; import { swInit } from "../lib/ServiceWorker";
import config from '../lib/config'; import responsiveSync from "../lib/VuexResponsiveSync";
import { installClickStrike } from "../lib/ClickStrike"; import { createChannel } from "../lib/ActionCable";
import VueProgressBar from "vue-progressbar";
import VueResize from "vue-resize";
import config from '../config';
import store from '../store';
import router from '../router'; import router from '../router';
import '../lib/GlobalMixins';
import App from '../components/App';
import AppAutocomplete from "../components/AppAutocomplete"; import AppAutocomplete from "../components/AppAutocomplete";
import AppConfirm from "../components/AppConfirm"; import AppConfirm from "../components/AppConfirm";
@ -23,40 +31,61 @@ import AppTagEditor from "../components/AppTagEditor";
import AppTextField from "../components/AppTextField"; import AppTextField from "../components/AppTextField";
import AppValidationErrors from "../components/AppValidationErrors"; import AppValidationErrors from "../components/AppValidationErrors";
import App from '../components/App.vue' 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);
// Resize?
// Progressbar? 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);
Vue.prototype.$createChannel = function(...args) {
createChannel(null, ...args);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const appElement = document.getElementById('app');
if (!appElement) { return; }
config.baseApiUrl = appElement.dataset.url; const app = document.getElementById('app');
config.baseApiUrl = app.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,39 +1,53 @@
import { createRouter, createWebHashHistory } from "vue-router"; import Vue from 'vue';
import { nextTick } from "vue"; import Router from 'vue-router';
import The404Page from '../components/The404Page'; import The404Page from './components/The404Page';
import TheAboutPage from '../components/TheAboutPage'; import TheAboutPage from './components/TheAboutPage';
import TheCalculator from '../components/TheCalculator'; import TheCalculator from './components/TheCalculator';
import TheLog from '../components/TheLog'; import TheLog from './components/TheLog';
import TheLogList from '../components/TheLogList'; import TheLogList from './components/TheLogList';
import TheLogCreator from '../components/TheLogCreator'; import TheLogCreator from './components/TheLogCreator';
import TheLogEditor from '../components/TheLogEditor'; import TheLogEditor from './components/TheLogEditor';
import TheFoodList from '../components/TheFoodList'; import TheFoodList from './components/TheFoodList';
import TheFood from "../components/TheFood"; import TheFood from "./components/TheFood";
import TheFoodEditor from "../components/TheFoodEditor"; import TheFoodEditor from "./components/TheFoodEditor";
import TheFoodCreator from "../components/TheFoodCreator"; import TheFoodCreator from "./components/TheFoodCreator";
import TheNotesList from '../components/TheNotesList'; import TheNotesList from './components/TheNotesList';
import TheRecipe from '../components/TheRecipe'; import TheRecipe from './components/TheRecipe';
import TheRecipeEditor from '../components/TheRecipeEditor'; import TheRecipeEditor from './components/TheRecipeEditor';
import TheRecipeCreator from '../components/TheRecipeCreator'; import TheRecipeCreator from './components/TheRecipeCreator';
import TheRecipeList from '../components/TheRecipeList'; import TheRecipeList from './components/TheRecipeList';
import TheTaskListList from '../components/TheTaskListList'; import TheTaskListList from './components/TheTaskListList';
import TheUserCreator from '../components/TheUserCreator'; import TheUserCreator from './components/TheUserCreator';
import TheUserEditor from '../components/TheUserEditor'; import TheUserEditor from './components/TheUserEditor';
import TheAdminUserList from '../components/TheAdminUserList'; import TheAdminUserList from './components/TheAdminUserList';
import TheAdminUserEditor from '../components/TheAdminUserEditor'; import TheAdminUserEditor from './components/TheAdminUserEditor';
import { useAppConfigStore } from "../stores/appConfig"; import $store from './store';
Vue.use(Router);
const router = createRouter({ const router = new Router({
history: createWebHashHistory(), routes: []
routes: [ });
router.afterEach((to, from) => {
if (to.meta.handleInitialLoad !== true && $store.state.initialLoad === false) {
$store.commit("setInitialLoad", true);
}
Vue.nextTick(() => {
document.title = to.meta.title || 'Parsley';
});
});
router.addRoutes(
[
{ {
path: '/', path: '/',
redirect: '/recipes' redirect: '/recipes'
@ -134,10 +148,10 @@ const router = createRouter({
{ {
path: "/logout", path: "/logout",
name: "logout", name: "logout",
beforeEnter: async (to, from, next) => { beforeEnter: (to, from, next) => {
const appConfig = useAppConfigStore(); const $store = router.app.$store;
await appConfig.logout(); $store.dispatch("logout")
return next("/"); .then(() => next("/"));
} }
}, },
@ -165,22 +179,10 @@ const router = createRouter({
}, },
{ {
path: '/:pathMatch(.*)*', path: '*',
name: 'NotFound',
component: The404Page 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; export default router;

View File

@ -0,0 +1,218 @@
import Vue from 'vue'
import Vuex from 'vuex'
import api from '../lib/Api';
import { createChannel } from '../lib/ActionCable';
Vue.use(Vuex);
let taskChannel = null;
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;
},
replaceTaskList(state, list) {
if (state.taskLists) {
const listIdx = state.taskLists.findIndex(l => l.id === list.id);
if (listIdx >= 0) {
state.taskLists.splice(listIdx, 1, list);
}
if (state.currentTaskList && state.currentTaskList.id === list.id) {
state.currentTaskList = list;
}
}
}
},
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, dispatch, 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);
dispatch('ensureTaskListChannel');
};
return api.getTaskLists(cb)
},
ensureTaskLists({dispatch, state}) {
if (state.user && state.taskLists.length === 0) {
return dispatch("refreshTaskLists");
} else {
return Promise.resolve();
}
},
ensureTaskListChannel({ commit }) {
if (taskChannel === null) {
taskChannel = createChannel(null, "TaskChannel", {
received(data) {
if (data && data.action === 'updated') {
commit('replaceTaskList', data.task_list);
}
}
});
}
},
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 => {
return data;
});
},
updateTaskItem({commit}, taskItem) {
return api.patchTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
return data;
});
},
deleteTaskItems({commit}, payload) {
return api.deleteTaskItems(payload.taskList.id, payload.taskItems);
},
completeTaskItems({commit}, payload) {
return api.completeTaskItems(payload.taskList.id, payload.taskItems, !payload.completed);
}
}
});

View File

@ -1,102 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,36 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<% <%
manifest_data = Shakapacker::manifest.refresh manifest_data = Webpacker::manifest.refresh
manifest_timestamp = File.mtime(Shakapacker::config.public_manifest_path).to_i manifest_timestamp = File.mtime(Webpacker::config.public_manifest_path).to_i
pack_assets = manifest_data.select { |name, asset| name !~ /\.map$/ && name != 'entrypoints' }.values pack_assets = manifest_data.select { |name, asset| name !~ /\.map$/ && name != 'entrypoints' }.values
%> %>

View File

@ -11,7 +11,6 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= javascript_pack_tag "application" %>
</head> </head>
<body> <body>

79
babel.config.js Normal file
View File

@ -0,0 +1,79 @@
module.exports = function(api) {
var validEnv = ['development', 'test', 'production']
var currentEnv = api.env()
var isDevelopmentEnv = api.env('development')
var isProductionEnv = api.env('production')
var isTestEnv = api.env('test')
if (!validEnv.includes(currentEnv)) {
throw new Error(
'Please specify a valid `NODE_ENV` or ' +
'`BABEL_ENV` environment variables. Valid values are "development", ' +
'"test", and "production". Instead, received: ' +
JSON.stringify(currentEnv) +
'.'
)
}
return {
presets: [
isTestEnv && [
'@babel/preset-env',
{
targets: {
node: 'current'
}
}
],
(isProductionEnv || isDevelopmentEnv) && [
'@babel/preset-env',
{
forceAllTransforms: true,
useBuiltIns: 'entry',
corejs: 3,
modules: false,
exclude: ['transform-typeof-symbol']
}
]
].filter(Boolean),
plugins: [
'babel-plugin-macros',
'@babel/plugin-syntax-dynamic-import',
isTestEnv && 'babel-plugin-dynamic-import-node',
'@babel/plugin-transform-destructuring',
[
'@babel/plugin-proposal-class-properties',
{
loose: true
}
],
[
'@babel/plugin-proposal-private-methods',
{
"loose": true
}
],
["@babel/plugin-proposal-private-property-in-object", { "loose": true }],
[
'@babel/plugin-proposal-object-rest-spread',
{
useBuiltIns: true
}
],
[
'@babel/plugin-transform-runtime',
{
helpers: false,
regenerator: true,
corejs: false
}
],
[
'@babel/plugin-transform-regenerator',
{
async: false
}
]
].filter(Boolean)
}
}

View File

@ -1,4 +1,4 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__) APP_PATH = File.expand_path('../config/application', __dir__)
require_relative "../config/boot" require_relative '../config/boot'
require "rails/commands" require 'rails/commands'

View File

@ -1,4 +1,4 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
require_relative "../config/boot" require_relative '../config/boot'
require "rake" require 'rake'
Rake.application.run Rake.application.run

View File

@ -1,11 +1,11 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
require "fileutils" require 'fileutils'
APP_ROOT = File.expand_path("..", __dir__) # path to your application root.
APP_NAME = "parsley" APP_ROOT = File.expand_path('..', __dir__)
def system!(*args) def system!(*args)
system(*args, exception: true) system(*args) || abort("\n== Command #{args} failed ==")
end end
FileUtils.chdir APP_ROOT do FileUtils.chdir APP_ROOT do
@ -13,28 +13,21 @@ FileUtils.chdir APP_ROOT do
# This script is idempotent, so that you can run it at anytime and get an expectable outcome. # This script is idempotent, so that you can run it at anytime and get an expectable outcome.
# Add necessary setup steps to this file. # Add necessary setup steps to this file.
puts "== Installing dependencies ==" puts '== Installing dependencies =='
system! "gem install bundler --conservative" system! 'gem install bundler --conservative'
system("bundle check") || system!("bundle install") system('bundle check') || system!('bundle install')
# Install JavaScript dependencies
system!("yarn install --no-immutable")
# puts "\n== Copying sample files ==" # puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml") # unless File.exist?('config/database.yml')
# FileUtils.cp "config/database.yml.sample", "config/database.yml" # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
# end # end
puts "\n== Preparing database ==" puts "\n== Preparing database =="
system! "bin/rails db:prepare" system! 'bin/rails db:prepare'
puts "\n== Removing old logs and tempfiles ==" puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear" system! 'bin/rails log:clear tmp:clear'
puts "\n== Restarting application server ==" puts "\n== Restarting application server =="
system! "bin/rails restart" system! 'bin/rails restart'
# puts "\n== Configuring puma-dev =="
# system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}"
# system "curl -Is https://#{APP_NAME}.test/up | head -n 1"
end end

View File

@ -1,13 +0,0 @@
#!/usr/bin/env ruby
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__)
require "bundler/setup"
require "shakapacker"
require "shakapacker/webpack_runner"
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Shakapacker::WebpackRunner.run(ARGV)
end

View File

@ -1,13 +0,0 @@
#!/usr/bin/env ruby
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__)
require "bundler/setup"
require "shakapacker"
require "shakapacker/dev_server_runner"
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Shakapacker::DevServerRunner.run(ARGV)
end

19
bin/webpack Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env ruby
ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"
require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)
require "rubygems"
require "bundler/setup"
require "webpacker"
require "webpacker/webpack_runner"
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::WebpackRunner.run(ARGV)
end

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