Compare commits

...

85 Commits
sw ... main

Author SHA1 Message Date
c26d94e504 Fix styles and rec title
All checks were successful
parsley/pipeline/head This commit looks good
2024-12-31 17:43:40 -06:00
659d7405b2 Fix dockerfile
All checks were successful
parsley/pipeline/head This commit looks good
2024-10-18 14:55:09 -05:00
169dda4d3b Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 14:50:37 -05:00
f5e32cf888 Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 14:42:04 -05:00
1b6b3135c7 Fix dockerfile 2024-10-18 14:39:41 -05:00
808c805cde Fix dockerfile
Some checks failed
parsley/pipeline/head There was a failure building this commit
2024-10-18 10:18:16 -05:00
7ead02ad7e progress and autosize 2024-10-02 16:20:07 -05:00
67c23015ab Continue converting to composition api 2024-10-02 14:34:50 -05:00
0d35f50dbf Continue converting to composition api 2024-10-01 09:32:09 -05:00
a071e6b21e Begin converting to composition api 2024-09-29 13:35:49 -05:00
b957d44aed style cleanup 2024-09-29 09:44:40 -05:00
f246f71aa9 Upgrade shakapacker, vue; switch to pinia 2024-09-28 20:58:25 -05:00
bb2e29f25c upgrade ruby/rails 2024-09-28 11:50:07 -05:00
552524e78b upgrade to shakapacker 2022-12-01 20:13:53 -06:00
2b77bc9fb8 rails 7/ruby 3 2022-12-01 18:02:26 -06:00
855b4ac779 Updated ruby; bumped gems 2022-11-22 13:10:47 -06:00
b311a7d7e8 Fixed actioncable
Some checks failed
parsley/pipeline/head There was a failure building this commit
2022-02-02 21:25:11 -06:00
7f1cf99237 Fix nginx 2022-02-02 21:19:33 -06:00
dd915624a3 update dependencies; live task editing 2022-02-02 21:12:27 -06:00
a306f72215 Small fixes 2022-02-02 15:55:24 -06:00
1a695b795e Update gitignore 2021-12-31 13:15:01 -06:00
64735b5ee5 Convert recipe to task list 2021-05-09 23:31:44 -05:00
e15bd576e6 Add extremely long session 2021-05-09 19:04:40 -05:00
66f6b5346d fix food ndbn selection caching; upgrade gems and ruby 2021-05-09 17:38:08 -05:00
6dcbb80794 Added markdown help 2020-08-30 17:43:47 -05:00
3b5b95292c finished removing jbuilder 2020-08-11 11:05:19 -05:00
7dfa978c26 more conversion away form jbuilder 2020-08-07 12:33:06 -05:00
08df218a00 begin removing jbuilder 2020-08-06 21:23:31 -05:00
f18c5a021c Fix recipe page history; fix caching 2020-08-06 20:26:45 -05:00
694bf43738 add default port to dockerfile 2020-05-04 17:59:11 -05:00
99d9cd587e create pid dir on startup 2020-05-04 17:53:31 -05:00
b85c2e1f60 ruby and rails upgrade 2020-05-02 14:12:04 -05:00
cf1294cf20 Fixed compose 2020-04-12 13:23:22 -05:00
9cc3150241 Fixed compose 2020-04-12 13:21:25 -05:00
5a5db7d299 updated env 2020-04-12 13:11:00 -05:00
bb6bf1559f bumped versions 2020-01-18 18:07:07 -06:00
997da180ff Removed traefik labels 2019-12-20 20:31:42 -06:00
050e8c0a12 Updated compose 2019-11-10 10:40:55 -06:00
ccb87d7019 Updated compose; added smooth transitions 2019-11-10 10:40:26 -06:00
eeb8e84344 Added package upgrade to Dockerfile 2019-09-13 11:46:24 -05:00
474e417dca Fixed service worker 2019-09-13 10:50:28 -05:00
ad9850a8e2 bumped deps 2019-09-10 17:04:33 -05:00
b79134ef9a fixed compose 2019-08-05 19:01:45 -05:00
f4fb33c6c3 upgraded gems 2019-08-05 18:54:49 -05:00
6115afca50 Added beta environment 2019-06-02 14:35:15 -05:00
9bcfeae9ef added angled parsley logo svg source 2019-03-27 20:58:33 -05:00
ab6f7cf5ee Update icon 2019-03-24 09:57:52 -05:00
cf09c5f000 Added favicons 2019-03-23 14:55:27 -05:00
bea495cd8a Fixed specs 2019-03-23 13:54:21 -05:00
123c943637 version update 2019-03-23 13:45:39 -05:00
e966a778c0 changed Dockerfile 2019-02-02 15:39:09 -06:00
d90f4f81bf enabled airbrake 2019-02-02 15:32:42 -06:00
521c62d09b add serving logic 2018-09-22 01:56:39 -05:00
c4ef70d0e8 fix ndbn 2018-09-15 08:50:03 -05:00
337d65c298 fix pg col 2018-09-14 19:46:02 -05:00
2e86208476 Added branded food DB 2018-09-14 19:32:49 -05:00
532c9372ea updates 2018-09-13 14:51:41 -05:00
0c4c5b899b updates 2018-09-12 17:17:15 -05:00
ffed63e0b3 fixed
Some checks failed
parsley/pipeline/head There was a failure building this commit
2018-09-12 14:17:18 -05:00
128bfcd535 bumped versions 2018-09-12 11:15:04 -05:00
b802542869 w 2018-09-12 09:43:50 -05:00
2fd83a5d3d front end work 2018-09-11 22:56:26 -05:00
47014118c8 work 2018-09-11 17:13:22 -05:00
56fe5aae35 recipe as ingredient work 2018-09-11 10:38:07 -05:00
a6cf7c1b16 icons 2018-09-10 17:25:36 -05:00
eb34393407 merged from master 2018-09-10 10:24:56 -05:00
d8720a8a6c fixed icon render 2018-09-09 21:01:17 -05:00
db02888776 icons 2018-09-09 16:37:25 -05:00
b1e5c22101 task lists 2018-09-07 21:56:13 -05:00
18603dc783 tasks
Some checks failed
parsley/pipeline/head There was a failure building this commit
2018-09-06 18:16:13 -05:00
4b25f753f1 tasks 2018-09-05 17:49:21 -05:00
41117e2e7f icon 2018-09-05 11:00:35 -05:00
813e54d8c4 icons 2018-08-29 16:58:07 -05:00
6aa2c8ee4a ui 2018-08-28 16:52:56 -05:00
9f0422acf8 start frontend 2018-08-28 10:39:11 -05:00
8992a4a082 backend 2018-08-27 17:46:33 -05:00
021c066cf4 rai 2018-08-27 16:44:45 -05:00
a2f2a05679 conditional deploy 2018-07-22 17:14:34 -05:00
f97c40bbeb jenkins 2018-07-22 17:09:11 -05:00
0d37ce2ab9 jenkins 2018-07-22 16:42:43 -05:00
10f50cb920 jenkins 2018-07-22 16:39:08 -05:00
b7a13019ae jenkins 2018-07-22 16:28:25 -05:00
f79a94fbcb jenkins 2018-07-22 16:25:55 -05:00
e3f57ac9cd jenkins 2018-07-22 16:15:53 -05:00
8f93591395 Fixed compose 2018-07-22 14:51:02 -05:00
544 changed files with 3732173 additions and 16796 deletions

View File

@ -1,18 +0,0 @@
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}]
],
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
["transform-class-properties", { "spec": true }]
]
}

View File

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

1
.foreman Normal file
View File

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

12
.gitignore vendored
View File

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

View File

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

View File

@ -1 +1 @@
2.4.4
3.3.5

View File

@ -1,50 +1,40 @@
FROM phusion/passenger-ruby24:latest
FROM ruby:3.3.5-bookworm
# Use baseimage-docker's init process.
CMD ["/sbin/my_init"]
RUN curl -sL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get update && apt-get dist-upgrade -y && \
apt-get install -y \
nodejs \
nginx && \
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Node
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs yarn && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN gem update --system && gem install bundler
# Enable Nginx / Passenger
RUN rm -f /etc/service/nginx/down
RUN gem update --no-document --system && gem install bundler --no-document && corepack enable
# Install nginx config files
RUN rm /etc/nginx/sites-enabled/default
ADD docker/nginx_server.conf /etc/nginx/sites-enabled/parsley.conf
ADD docker/nginx_env.conf /etc/nginx/main.d/env.conf
# Add DB Migration Script
RUN mkdir -p /etc/my_init.d
ADD docker/db_migrate.sh /etc/my_init.d/db_migrate.sh
RUN chmod +x /etc/my_init.d/db_migrate.sh
# Add scripts
ADD docker/bin/* /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/nginx_service.sh /usr/local/bin/rails_service.sh
# Set Default RAILS_ENV
ENV RAILS_ENV docker
ENV PASSENGER_APP_ENV docker
# Setup directory and install gems
RUN mkdir -p /home/app/parsley/log /home/app/parsley/tmp
RUN chown -R app:app /home/app/parsley/
WORKDIR /home/app/parsley/
RUN mkdir -p /parsley
WORKDIR /parsley
COPY Gemfile* ./
RUN bundle install --deployment --jobs 4 --without development test
RUN bundle install
COPY package.json yarn.lock ./
RUN yarn install --production=true
RUN yarn install --immutable
# Copy the app into the image
COPY --chown="app" . .
COPY . .
# Compile assets
RUN su app -c "bundle exec rails webpacker:clobber webpacker:compile"
RUN env RAILS_ENV=production bundle exec rails shakapacker:clobber shakapacker:compile
ENV PORT=80
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

39
Gemfile
View File

@ -1,37 +1,30 @@
source 'https://rubygems.org'
gem 'rails', '5.2.0'
gem 'pg', '~> 1.0.0'
gem 'rails', '7.2.1'
gem 'pg', '~> 1.5.8'
gem 'webpacker', '3.5.3'
gem 'shakapacker', '8.0.2'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'jbuilder', '~> 2.7'
#gem 'jbuilder', git: 'https://github.com/rails/jbuilder', branch: 'master'
gem 'oj', '~> 3.16.6'
gem 'csv', '~> 3.3'
gem 'oj', '~> 3.6.2'
gem 'kaminari', '~> 1.1.1'
gem 'unitwise', '~> 2.2.0'
gem 'redcarpet', '~> 3.4.0'
gem 'dalli', '~> 2.7.6'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.11'
gem 'kaminari', '~> 1.2.2'
gem 'unitwise', '~> 2.3.0'
gem 'redcarpet', '~> 3.6.0'
gem 'dalli', '~> 3.2.8'
gem 'puma', '~> 6.4'
gem 'bcrypt', '~> 3.1.18'
gem 'tzinfo-data'
group :development, :test do
gem 'sqlite3', '~> 2.1.0'
gem 'puma', '~> 3.11'
gem 'sqlite3'
gem 'guard', '~> 2.14.0'
gem 'guard', '~> 2.18.0'
gem 'guard-rspec', require: false
gem 'rspec-rails', '~> 3.7.2'
gem 'rspec-rails', '~> 7.0.1'
gem 'rails-controller-testing'
gem 'factory_bot_rails', '~> 4.8.2'
gem 'database_cleaner', '~> 1.6.2'
gem 'factory_bot_rails', '~> 6.4.3'
gem 'database_cleaner', '~> 2.0.2'
end

View File

@ -1,75 +1,114 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (5.2.0)
actionpack (= 5.2.0)
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.0)
actionpack (= 5.2.0)
actionview (= 5.2.0)
activejob (= 5.2.0)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.0)
actionview (= 5.2.0)
activesupport (= 5.2.0)
rack (~> 2.0)
zeitwerk (~> 2.6)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.0)
activesupport (= 5.2.0)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1)
activesupport (= 7.2.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
activejob (5.2.0)
activesupport (= 5.2.0)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.3.6)
activemodel (5.2.0)
activesupport (= 5.2.0)
activerecord (5.2.0)
activemodel (= 5.2.0)
activesupport (= 5.2.0)
arel (>= 9.0)
activestorage (5.2.0)
actionpack (= 5.2.0)
activerecord (= 5.2.0)
marcel (~> 0.3.1)
activesupport (5.2.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
arel (9.0.0)
bcrypt (3.1.12)
bootsnap (1.3.0)
msgpack (~> 1.0)
builder (3.2.3)
coderay (1.1.2)
concurrent-ruby (1.0.5)
crass (1.0.4)
dalli (2.7.8)
database_cleaner (1.6.2)
diff-lcs (1.3)
erubi (1.7.1)
factory_bot (4.8.2)
activesupport (>= 3.0.0)
factory_bot_rails (4.8.2)
factory_bot (~> 4.8.2)
railties (>= 3.0.0)
ffi (1.9.25)
formatador (0.2.5)
globalid (0.4.1)
activesupport (>= 4.2.0)
guard (2.14.2)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
timeout (>= 0.4.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
marcel (~> 1.0)
activesupport (7.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.8)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
coderay (1.1.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
csv (3.3.0)
dalli (3.2.8)
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.2.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.4)
diff-lcs (1.5.1)
drb (2.2.1)
erubi (1.13.0)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
ffi (1.17.0)
formatador (1.1.0)
globalid (1.2.1)
activesupport (>= 6.1)
guard (2.18.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
lumberjack (>= 1.0.12, < 2.0)
nenv (~> 0.1)
notiffany (~> 0.0)
pry (>= 0.9.12)
pry (>= 0.13.0)
shellany (~> 0.0)
thor (>= 0.18.1)
guard-compat (1.2.1)
@ -77,172 +116,207 @@ GEM
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
i18n (1.0.1)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
jbuilder (2.7.0)
activesupport (>= 4.2.0)
multi_json (>= 1.2)
kaminari (1.1.1)
io-console (0.7.2)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.1.1)
kaminari-activerecord (= 1.1.1)
kaminari-core (= 1.1.1)
kaminari-actionview (1.1.1)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.1.1)
kaminari-activerecord (1.1.1)
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.1.1)
kaminari-core (1.1.1)
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
liner (0.2.4)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
loofah (2.2.2)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
lumberjack (1.0.13)
mail (2.7.0)
nokogiri (>= 1.12.0)
lumberjack (1.2.10)
mail (2.8.1)
mini_mime (>= 0.1.1)
marcel (0.3.2)
mimemagic (~> 0.3.2)
net-imap
net-pop
net-smtp
marcel (1.0.4)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (0.9.0)
mimemagic (0.3.2)
mini_mime (1.0.0)
mini_portile2 (2.3.0)
minitest (5.11.3)
msgpack (1.2.4)
multi_json (1.13.1)
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.25.1)
msgpack (1.7.2)
nenv (0.3.0)
nio4r (2.3.1)
nokogiri (1.8.2)
mini_portile2 (~> 2.3.0)
notiffany (0.1.1)
net-imap (0.4.16)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
oj (3.6.2)
parslet (1.8.2)
pg (1.0.0)
pry (0.11.3)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
puma (3.11.4)
rack (2.0.5)
rack-proxy (0.6.4)
oj (3.16.6)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
ostruct (0.6.0)
package_json (0.1.0)
parslet (2.0.0)
pg (1.5.8)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
psych (5.1.2)
stringio
puma (6.4.3)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.7)
rack-proxy (0.7.7)
rack
rack-test (1.0.0)
rack (>= 1.0, < 3)
rails (5.2.0)
actioncable (= 5.2.0)
actionmailer (= 5.2.0)
actionpack (= 5.2.0)
actionview (= 5.2.0)
activejob (= 5.2.0)
activemodel (= 5.2.0)
activerecord (= 5.2.0)
activestorage (= 5.2.0)
activesupport (= 5.2.0)
bundler (>= 1.3.0)
railties (= 5.2.0)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1)
actionview (~> 5.x, >= 5.0.1)
activesupport (~> 5.x)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0)
railties (= 7.2.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
railties (5.2.0)
actionpack (= 5.2.0)
activesupport (= 5.2.0)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rake (12.3.1)
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
redcarpet (3.4.0)
rspec (3.7.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-core (3.7.1)
rspec-support (~> 3.7.0)
rspec-expectations (3.7.0)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
reline (0.5.10)
io-console (~> 0.5)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.1)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-mocks (3.7.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-rails (3.7.2)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-support (~> 3.7.0)
rspec-support (3.7.1)
ruby_dep (1.5.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
securerandom (0.3.1)
semantic_range (3.0.0)
shakapacker (8.0.2)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
shellany (0.0.1)
signed_multiset (0.2.1)
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.1)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.3.13)
thor (0.20.0)
sqlite3 (2.1.0)
mini_portile2 (~> 2.8.0)
stringio (3.1.1)
thor (1.3.2)
thread_safe (0.3.6)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2018.5)
timeout (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
unitwise (2.2.0)
unitwise (2.3.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 1.5)
parslet (~> 2.0)
signed_multiset (~> 0.2)
webpacker (3.5.3)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
websocket-driver (0.7.0)
useragent (0.16.10)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3)
websocket-extensions (0.1.5)
zeitwerk (2.6.18)
PLATFORMS
ruby
DEPENDENCIES
bcrypt (~> 3.1.11)
bcrypt (~> 3.1.18)
bootsnap (>= 1.1.0)
dalli (~> 2.7.6)
database_cleaner (~> 1.6.2)
factory_bot_rails (~> 4.8.2)
guard (~> 2.14.0)
csv (~> 3.3)
dalli (~> 3.2.8)
database_cleaner (~> 2.0.2)
factory_bot_rails (~> 6.4.3)
guard (~> 2.18.0)
guard-rspec
jbuilder (~> 2.7)
kaminari (~> 1.1.1)
oj (~> 3.6.2)
pg (~> 1.0.0)
puma (~> 3.11)
rails (= 5.2.0)
kaminari (~> 1.2.2)
oj (~> 3.16.6)
pg (~> 1.5.8)
puma (~> 6.4)
rails (= 7.2.1)
rails-controller-testing
redcarpet (~> 3.4.0)
rspec-rails (~> 3.7.2)
sqlite3
redcarpet (~> 3.6.0)
rspec-rails (~> 7.0.1)
shakapacker (= 8.0.2)
sqlite3 (~> 2.1.0)
tzinfo-data
unitwise (~> 2.2.0)
webpacker (= 3.5.3)
unitwise (~> 2.3.0)
BUNDLED WITH
1.16.1
2.5.20

27
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,27 @@
library('jenkins_build')
node {
main {
def dockerImageName = "registry.elbert.us/parsley"
def dockerImage
stage("Checkout") {
checkout scm
}
stage("Build") {
dockerImage = docker.build("${dockerImageName}:latest")
}
stage("Publish") {
dockerImage.push()
dockerImage.push(env.JOB_BASE_NAME)
}
if (env.BRANCH_NAME == "production") {
stage("Deploy") {
remote_deploy("azathoth.thenever", "parsley", "./docker-compose-azathoth.yml")
}
}
}
}

View File

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

2
Procfile Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if verified_user = User.find_by(id: cookies.encrypted['_parsley_session']['user_id'])
verified_user
else
reject_unauthorized_connection
end
end
end
end

View File

@ -0,0 +1,12 @@
class TaskChannel < ApplicationCable::Channel
def subscribed
stream_for current_user.id
end
def self.update_task_list(task_list)
task_list.reload
self.broadcast_to task_list.user_id, { task_list: TaskListSerializer.for(task_list), action: 'updated' }
end
end

View File

@ -6,6 +6,7 @@ module Admin
def index
@users = User.order(:full_name)
render json: UserSerializer.for(@users)
end
def show

View File

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

View File

@ -7,29 +7,62 @@ class CalculatorController < ApplicationController
def calculate
input = params[:input]
output_unit = params[:output_unit]
ingredient_id = params[:ingredient_id]
ingredient = nil
density = params[:density]
density = nil unless density.present?
data = {errors: [], output: ''}
if ingredient_id.present?
ingredient = Ingredient.find_by_ingredient_id(ingredient_id)
end
begin
unit = UnitConversion.parse(input)
if output_unit.present?
unit = unit.convert(output_unit, density)
data[:output] = unit.to_s
else
data[:output] = unit.auto_unit.to_s
data = {errors: Hash.new { |h, k| h[k] = [] }, output: ''}
UnitConversion::with_custom_units(ingredient ? ingredient.custom_units : []) do
density_unit = nil
begin
if density
density_unit = UnitConversion.parse(density)
unless density_unit.density?
data[:errors][:density] << 'not a density unit'
density_unit = nil
end
end
rescue UnitConversion::UnparseableUnitError => e
data[:errors][:density] << 'invalid string'
end
rescue UnitConversion::UnparseableUnitError => e
data[:errors] << e.message
begin
input_unit = UnitConversion.parse(input)
input_unit.unitwise
rescue UnitConversion::UnparseableUnitError => e
data[:errors][:input] << e.message
input_unit = nil
end
if !input_unit.nil?
if output_unit.present?
begin
input_unit = input_unit.convert(output_unit, density_unit)
rescue UnitConversion::UnparseableUnitError => e
data[:errors][:output_unit] << e.message
end
else
input_unit = input_unit.auto_unit
end
end
if data[:errors].empty?
data[:output] = input_unit.to_s
end
end
render json: data
end
def ingredient_search
@ingredients = Ingredient.has_density.search(params[:query]).order(:name)
@foods = Food.has_density.search(params[:query]).order(:name)
render json: @foods.map { |f| {id: f.id, name: f.name, density: f.density} }
end
end

View File

@ -0,0 +1,109 @@
class FoodsController < ApplicationController
before_action :set_food, only: [:show, :update, :destroy]
before_action :ensure_valid_user, except: [:index, :show]
# GET /foods
# GET /foods.json
def index
@foods = Food.all.order(:name).page(params[:page]).per(params[:per])
if params[:name].present?
@foods = @foods.matches_tokens(:name, params[:name].split.take(4))
end
render json: FoodSummarySerializer.for(@foods, collection_name: 'foods')
end
def show
render json: FoodSerializer.for(@food)
end
# POST /foods
# POST /foods.json
def create
@food = Food.new(food_params)
@food.user = current_user
if @food.ndbn.present?
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
end
respond_to do |format|
if @food.save
format.html { redirect_to foods_path, notice: 'Ingredient was successfully created.' }
format.json { render json: { success: true }, status: :created, location: @food }
else
format.html { render :new }
format.json { render json: @food.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /foods/1
def update
ensure_owner @food do
@food.assign_attributes(food_params)
if @food.ndbn.present?
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
end
respond_to do |format|
if @food.save
format.html { redirect_to foods_path, notice: 'Ingredient was successfully updated.' }
format.json { render json: { success: true }, status: :ok, location: @food }
else
format.html { render :edit }
format.json { render json: @food.errors, status: :unprocessable_entity }
end
end
end
end
# DELETE /foods/1
# DELETE /foods/1.json
def destroy
ensure_owner @food do
@food.destroy
respond_to do |format|
format.html { redirect_to foods_url, notice: 'Ingredient was successfully destroyed.' }
format.json { head :no_content }
end
end
end
def select_ndbn
if params[:id].present?
@food = Food.find(params[:id])
else
@food = Food.new
end
@food.assign_attributes(food_params)
if @food.ndbn.present?
@food.set_usda_food(UsdaFood.find_by_ndbn(@food.ndbn))
end
render json: FoodSerializer.for(@food)
end
def usda_food_search
@foods = UsdaFood.search(params[:query]).limit(250).order(:long_description)
render json: UsdaFoodSerializer.for(@foods)
end
private
# Use callbacks to share common setup or constraints between actions.
def set_food
@food = Food.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def food_params
params.require(:food).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ash, :iron, :magnesium, :phosphorus, :potassium, :zinc, :copper, :manganese, :vit_c, :vit_b6, :vit_b12, :vit_a, :vit_e, :vit_d, :cholesterol, :food_units_attributes => [:name, :gram_weight, :id, :_destroy])
end
def conversion_params
params.require(:conversion).permit(:input_quantity, :input_units, :scale, :output_units, :ingredient_id)
end
end

View File

@ -1,141 +1,19 @@
class IngredientsController < ApplicationController
before_action :set_ingredient, only: [:show, :edit, :update, :destroy]
before_action :ensure_valid_user, except: [:index, :show]
# GET /ingredients
# GET /ingredients.json
def index
@ingredients = Ingredient.all.order(:name).page(params[:page]).per(params[:per])
if params[:name].present?
@ingredients = @ingredients.matches_tokens(:name, params[:name].split.take(4))
end
end
def show
end
# GET /ingredients/new
def new
@ingredient = Ingredient.new
end
# GET /ingredients/1/edit
def edit
ensure_owner @ingredient
end
# POST /ingredients
# POST /ingredients.json
def create
@ingredient = Ingredient.new(ingredient_params)
@ingredient.user = current_user
if @ingredient.ndbn.present?
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
end
respond_to do |format|
if @ingredient.save
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
format.json { render json: { success: true }, status: :created, location: @ingredient }
else
format.html { render :new }
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /ingredients/1
def update
ensure_owner @ingredient do
@ingredient.assign_attributes(ingredient_params)
if @ingredient.ndbn.present?
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
end
respond_to do |format|
if @ingredient.save
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' }
format.json { render json: { success: true }, status: :ok, location: @ingredient }
else
format.html { render :edit }
format.json { render json: @ingredient.errors, status: :unprocessable_entity }
end
end
end
end
# DELETE /ingredients/1
# DELETE /ingredients/1.json
def destroy
ensure_owner @ingredient do
@ingredient.destroy
respond_to do |format|
format.html { redirect_to ingredients_url, notice: 'Ingredient was successfully destroyed.' }
format.json { head :no_content }
end
end
end
def select_ndbn
if params[:id].present?
@ingredient = Ingredient.find(params[:id])
else
@ingredient = Ingredient.new
end
@ingredient.assign_attributes(ingredient_params)
if @ingredient.ndbn.present?
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
end
render :show
end
def prefetch
@ingredients = Ingredient.all.order(:name)
render :search
end
def search
@ingredients = Ingredient.search(params[:query]).order(:name)
@ingredients = Food.search(params[:query]).order(:name).to_a
@ingredients.concat(Recipe.is_ingredient.search_by_name(params[:query]).order(:name).to_a)
@ingredients.sort { |a, b| a.name <=> b.name }
json = @ingredients.map do |i|
{
id: i.ingredient_id,
ingredient_id: i.ingredient_id,
name: i.name,
density: i.density
}
end
render json: json
end
def convert
@conversion = Conversion.new(conversion_params)
if @conversion.valid?
@output_quantity = @conversion.output_quantity
@conversion = Conversion.new
else
@output_quantity = ''
end
end
def usda_food_search
@foods = UsdaFood.search(params[:query]).limit(250).order(:long_description)
respond_to do |format|
format.html { render :layout => false }
format.json { }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_ingredient
@ingredient = Ingredient.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def ingredient_params
params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :carbohydrates, :kcal, :fiber, :sugar, :calcium, :sodium, :vit_k, :ash, :iron, :magnesium, :phosphorus, :potassium, :zinc, :copper, :manganese, :vit_c, :vit_b6, :vit_b12, :vit_a, :vit_e, :vit_d, :cholesterol, :ingredient_units_attributes => [:name, :gram_weight, :id, :_destroy])
end
def conversion_params
params.require(:conversion).permit(:input_quantity, :input_units, :scale, :output_units, :ingredient_id)
end
end

View File

@ -8,14 +8,17 @@ class LogsController < ApplicationController
def index
@logs = Log.for_user(current_user).order(date: :desc).page(params[:page]).per(params[:per])
render json: LogSummarySerializer.for(@logs, collection_name: 'logs')
end
def show
ensure_owner(@log)
render json: LogSerializer.for(@log)
end
def edit
ensure_owner(@log)
render json: LogSerializer.for(@log)
end
def update
@ -61,12 +64,12 @@ class LogsController < ApplicationController
private
def set_log
@log = Log.includes({recipe: {recipe_ingredients: {ingredient: :ingredient_units} }}).find(params[:id])
@log = Log.includes({recipe: {recipe_ingredients: {food: :food_units} }}).find(params[:id])
end
def set_recipe
if params[:recipe_id].present?
@recipe = Recipe.includes([{recipe_ingredients: [:ingredient]}]).find(params[:recipe_id])
@recipe = Recipe.includes([{recipe_ingredients: [:food]}]).find(params[:recipe_id])
end
end

View File

@ -1,27 +1,19 @@
class NotesController < ApplicationController
before_action :set_note, only: [:show, :edit, :update, :destroy]
before_action :set_note, only: [:show, :update, :destroy]
before_action :ensure_valid_user
# GET /notes
# GET /notes.json
def index
@notes = Note.for_user(current_user)
render json: NoteSerializer.for(@notes)
end
# GET /notes/1
# GET /notes/1.json
def show
ensure_owner(@note)
end
# GET /notes/new
def new
@note = Note.new
end
# GET /notes/1/edit
def edit
ensure_owner(@note)
render json: NoteSerializer.for(@note)
end
# POST /notes
@ -33,7 +25,7 @@ class NotesController < ApplicationController
respond_to do |format|
if @note.save
format.html { redirect_to notes_path, notice: 'Note was successfully created.' }
format.json { render :show, status: :created, location: @note }
format.json { render json: NoteSerializer.for(@note), status: :created, location: @note }
else
format.html { render :new }
format.json { render json: @note.errors, status: :unprocessable_entity }
@ -48,7 +40,7 @@ class NotesController < ApplicationController
respond_to do |format|
if @note.update(note_params)
format.html { redirect_to notes_path, notice: 'Note was successfully updated.' }
format.json { render :show, status: :ok, location: @note }
format.json { render json: NoteSerializer.for(@note), status: :ok, location: @note }
else
format.html { render :edit }
format.json { render json: @note.errors, status: :unprocessable_entity }

View File

@ -1,13 +1,14 @@
class RecipesController < ApplicationController
before_action :set_recipe, only: [:show, :edit, :update, :destroy]
before_action :set_recipe, only: [:show, :update, :destroy]
before_action :ensure_valid_user, except: [:show, :scale, :index]
before_action :ensure_valid_user, except: [:show, :index]
# GET /recipes
def index
@criteria = ViewModels::RecipeCriteria.new(criteria_params)
@recipes = Recipe.for_criteria(@criteria).includes(:tags)
render json: RecipeSummarySerializer.for(@recipes, collection_name: 'recipes')
end
# GET /recipes/1
@ -38,6 +39,7 @@ class RecipesController < ApplicationController
end
end
render json: RecipeSerializer.for(@recipe)
end
# POST /recipes
@ -55,7 +57,8 @@ class RecipesController < ApplicationController
# PATCH/PUT /recipes/1
def update
ensure_owner(@recipe) do
if @recipe.update(recipe_params)
# Merge in updated_at to force the record to be dirty (in case only tags were changed)
if @recipe.update(recipe_params.merge(updated_at: Time.now))
render json: { success: true }
else
render json: @recipe.errors, status: :unprocessable_entity
@ -81,12 +84,12 @@ class RecipesController < ApplicationController
private
# Use callbacks to share common setup or constraints between actions.
def set_recipe
@recipe = Recipe.includes(recipe_ingredients: {ingredient: :ingredient_units }).find(params[:id])
@recipe = Recipe.includes(recipe_ingredients: [{food: :food_units }, {recipe_as_ingredient: {recipe_ingredients: {food: :food_units }}}]).find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def recipe_params
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, :is_ingredient, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
end
def criteria_params

View File

@ -6,11 +6,12 @@ class TagsController < ApplicationController
def prefetch
@tags = Tag.all.order(:name)
render :search
render json: TagSerializer.for(@tags)
end
def search
@tags = Tag.search(params[:query]).order(:name)
render json: TagSerializer.for(@tags)
end
end

View File

@ -0,0 +1,68 @@
class TaskItemsController < ApplicationController
before_action :ensure_valid_user
before_action :set_task_list
before_action :set_task_item, only: [:update]
def create
@task_item = TaskItem.new(task_item_params)
@task_item.task_list = @task_list
if @task_item.save
TaskChannel.update_task_list(@task_list)
render json: TaskItemSerializer.for(@task_item), status: :created, location: [@task_list, @task_item]
else
render json: @task_item.errors, status: :unprocessable_entity
end
end
def update
if @task_item.update(task_item_params)
TaskChannel.update_task_list(@task_list)
render json: TaskItemSerializer.for(@task_item), status: :ok, location: [@task_list, @task_item]
else
render json: @task_item.errors, status: :unprocessable_entity
end
end
def destroy
ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
TaskItem.transaction do
@task_items = @task_list.task_items.find(ids)
@task_items.each { |i| i.destroy }
end
TaskChannel.update_task_list(@task_list)
head :no_content
end
def complete
ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
new_status = !params[:invert].present?
TaskItem.transaction do
@task_items = @task_list.task_items.find(ids)
@task_items.each { |i| i.update_attribute(:completed, new_status) }
end
TaskChannel.update_task_list(@task_list)
head :no_content
end
private
def task_item_params
params.require(:task_item).permit(:name, :quantity, :completed)
end
def set_task_list
@task_list = TaskList.find(params[:task_list_id])
ensure_owner(@task_list)
end
def set_task_item
@task_item = @task_list.task_items.find(params[:id])
end
end

View File

@ -0,0 +1,66 @@
class TaskListsController < ApplicationController
before_action :ensure_valid_user
before_action :set_task_list, only: [:show, :update, :destroy, :add_recipe]
def index
@task_lists = TaskList.for_user(current_user).includes(:task_items).order(created_at: :desc)
render json: TaskListSerializer.for(@task_lists)
end
def show
ensure_owner(@task_list)
render json: TaskListSerializer.for(@task_list)
end
def create
@task_list = TaskList.new(task_list_params)
@task_list.user = current_user
if @task_list.save
render json: TaskListSerializer.for(@task_list), status: :created, location: @task_list
else
render json: @task_list.errors, status: :unprocessable_entity
end
end
def update
ensure_owner(@task_list) do
if @task_list.update(task_list_params)
TaskChannel.update_task_list(@task_list)
render json: TaskListSerializer.for(@task_list), status: :ok, location: @task_list
else
render json: @task_list.errors, status: :unprocessable_entity
end
end
end
def destroy
ensure_owner(@task_list) do
@task_list.destroy
head :no_content
end
end
def add_recipe
ensure_owner(@task_list) do
recipe = Recipe.find(params[:recipe_id])
@task_list.add_recipe_ingredients(recipe)
TaskChannel.update_task_list(@task_list)
head :no_content
end
end
private
def task_list_params
params.require(:task_list).permit(:name)
end
def set_task_list
@task_list = TaskList.find(params[:id])
end
end

View File

@ -1,11 +1,14 @@
class UsersController < ApplicationController
UserProxy = Struct.new(:user_id)
before_action :ensure_valid_user, except: [:show, :login, :verify_login, :new, :create]
skip_before_action :verify_authenticity_token, only: [:verify_login]
def show
if current_user
render json: UserSerializer.for(current_user)
else
render json: nil
end
end
def login
@ -28,7 +31,7 @@ class UsersController < ApplicationController
if user = User.authenticate(params[:username], params[:password])
set_current_user(user)
format.html { redirect_to root_path, notice: "Welcome, #{user.display_name}" }
format.json { render json: { success: true, user: { id: user.id, name: user.display_name, admin: user.admin? } } }
format.json { render json: { success: true, user: UserSerializer.for(current_user).serialize } }
else
format.html { flash[:error] = "Invalid credentials"; render :login }
format.json { render json: { success: false, message: 'Invalid Credentials', user: nil } }
@ -47,7 +50,7 @@ class UsersController < ApplicationController
if @user.save
set_current_user(@user)
format.html { redirect_to root_path, notice: 'User created.' }
format.json { render :show, status: :created, location: @user }
format.json { render json: UserSerializer.for(@user), status: :created, location: @user }
else
format.html { render :new }
format.json { render json: @user.errors, status: :unprocessable_entity }
@ -65,7 +68,7 @@ class UsersController < ApplicationController
respond_to do |format|
if @user.update(user_params)
format.html { redirect_to root_path, notice: 'User updated.' }
format.json { render :show, status: :created, location: @user }
format.json { render json: UserSerializer.for(@user) , status: :created, location: @user }
else
format.html { render :edit }
format.json { render json: @user.errors, status: :unprocessable_entity }

View File

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

View File

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

View File

@ -1,77 +1,67 @@
<template>
<div>
<vue-progress-bar></vue-progress-bar>
<div id="app">
<app-progress-bar></app-progress-bar>
<app-navbar></app-navbar>
<section id="app" class="">
<section id="main" class="">
<div class="container">
<router-view v-if="!hasError"></router-view>
<div v-else>
<h1>Error!</h1>
<p>{{error}}</p>
</div>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component v-if="!hasError" :is="Component" />
<div v-else>
<h1>Error!</h1>
<p>{{ appConfig.error }}</p>
</div>
</transition>
</router-view>
</div>
</section>
</div>
</template>
<script>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
import { useAppConfigStore } from "../stores/appConfig";
import { useLoadResource } from "../lib/useLoadResource";
import { useCheckAuthentication } from "../lib/useCheckAuthentication";
import AppProgressBar from "./AppProgressBar.vue";
import { mapMutations, mapState } from "vuex";
import api from "../lib/Api";
const globalTweenGroup = useGlobalTweenGroup();
let animationLoop = true;
export default {
data() {
return {
api: api
};
},
computed: {
...mapState({
hasError: state => state.error !== null,
error: state => state.error,
authChecked: state => state.authChecked
})
},
const appConfig = useAppConfigStore();
const hasError = computed(() => appConfig.error !== null);
watch: {
isLoading(val) {
const { loadResource } = useLoadResource();
const { checkAuthentication } = useCheckAuthentication(loadResource);
watch(
() => appConfig.initialLoad,
(val) => {
if (val) {
this.$Progress.start();
} else {
this.$Progress.finish();
nextTick(() => document.body.classList.remove("loading"));
}
},
{ immediate: true }
);
onMounted(() => {
// Setup global animation loop
function animate() {
if (animationLoop) {
globalTweenGroup.update();
requestAnimationFrame(animate);
}
},
created() {
if (this.user === null && this.authChecked === false) {
this.checkAuthentication();
}
// Hard coded values taken directly from Bulma css
const mediaQueries = {
mobile: "screen and (max-width: 768px)",
tablet: "screen and (min-width: 769px)",
tabletOnly: "screen and (min-width: 769px) and (max-width: 1023px)",
touch: "screen and (max-width: 1023px)",
desktop: "screen and (min-width: 1024px)",
desktopOnly: "screen and (min-width: 1024px) and (max-width: 1215px)",
widescreen: "screen and (min-width: 1216px)",
widescreenOnly: "screen and (min-width: 1216px) and (max-width: 1407px)",
fullhd: "screen and (min-width: 1408px)"
};
for (let device in mediaQueries) {
const query = window.matchMedia(mediaQueries[device]);
query.onchange = (q) => {
this.$store.commit("setMediaQuery", {mediaName: device, value: q.matches});
};
query.onchange(query);
}
},
components: {
}
}
animate();
if (appConfig.user === null && appConfig.authChecked === false) {
checkAuthentication();
}
});
onUnmounted(() => {
animationLoop = false;
});
</script>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,81 @@
<template>
<div ref="dropdown" class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}">
<div class="dropdown-trigger">
<slot name="button">
<button type="button" class="button" :class="buttonClass" @click="toggle">
<span>{{ label }}</span>
<app-icon icon="caret-bottom" size="xs"></app-icon>
</button>
</slot>
</div>
<div class="dropdown-menu">
<div class="dropdown-content">
<slot>
Default Content
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { useTemplateRef } from "vue";
import { onClickOutside } from '@vueuse/core'
const emit = defineEmits(["close", "open"]);
const props = defineProps({
open: {
required: false,
type: Boolean,
default: false
},
hover: {
required: false,
type: Boolean,
default: false
},
label: {
required: false,
type: String,
default: 'Select'
},
buttonClass: {
required: false,
default: ""
}
});
const dropdownElement = useTemplateRef("dropdown");
onClickOutside(dropdownElement, event => handleOutsideClick(event))
function toggle() {
if (props.open) {
triggerClose();
} else {
triggerOpen();
}
}
function triggerOpen() {
emit("open");
}
function triggerClose() {
emit("close");
}
function handleOutsideClick(evt) {
if (props.open) {
if (!dropdownElement.value.contains(evt.target)) {
triggerClose();
}
}
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<transition
name="expand"
@enter="enter"
@leave="leave"
@enter-cancel="cancel"
@leave-cancel="cancel">
<slot></slot>
</transition>
</template>
<script setup>
import TWEEN from '@tweenjs/tween.js';
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
const props = defineProps({
expandTime: {
type: Number,
default: 250
}
});
let animation = null;
function cancel () {
if (animation) {
animation.stop();
animation = null;
}
}
function enter(element, done) {
const width = parseInt(getComputedStyle(element).width);
const paddingTop = parseInt(getComputedStyle(element).paddingTop);
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
element.style.width = width;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
element.style.height = 'auto';
const height = parseInt(getComputedStyle(element).height);
element.style.width = null;
element.style.position = null;
element.style.visibility = null;
element.style.overflow = 'hidden';
element.style.height = 0;
animation = new TWEEN.Tween({height: 0, paddingTop: 0, paddingBottom: 0})
.to({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom}, props.expandTime)
.onUpdate(obj => {
element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px";
})
.onComplete(() => {
animation = null;
element.removeAttribute('style');
element.style.opacity = 0.99;
setTimeout(() => {
// Fixes odd drawing bug in Chrome
element.style.opacity = 1.0;
}, 1000);
done();
})
.group(useGlobalTweenGroup())
.start();
}
function leave(element, done) {
const height = parseInt(getComputedStyle(element).height);
const paddingTop = parseInt(getComputedStyle(element).paddingTop);
const paddingBottom = parseInt(getComputedStyle(element).paddingBottom);
element.style.overflow = 'hidden';
animation = new TWEEN.Tween({height: height, paddingTop: paddingTop, paddingBottom: paddingBottom})
.to({height: 0, paddingTop: 0, paddingBottom: 0}, props.expandTime)
.onUpdate(obj => {
element.style.height = obj.height + "px";
element.style.paddingBottom = obj.paddingBottom + "px";
element.style.paddingTop = obj.paddingTop + "px";
})
.onComplete(() => {
animation = null;
element.removeAttribute('style');
done();
})
.group(useGlobalTweenGroup())
.start();
}
</script>
<style scoped>
* {
will-change: height;
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
</style>

View File

@ -1,47 +1,57 @@
<template>
<span class="icon" :class="sizeClass" @click="$emit('click', $event)">
<svg v-html="svgContent" v-bind="svgAttributes"></svg>
<span class="icon" :class="iconClasses" @click="$emit('click', $event)">
<app-iconic-icon :icon="iconicIcon" :size="iconicSize" v-bind="iconicAttributes"></app-iconic-icon>
</span>
</template>
<script>
import CaretBottom from "open-iconic/svg/caret-bottom";
import CaretTop from "open-iconic/svg/caret-top";
import Check from "open-iconic/svg/check";
import CircleCheck from "open-iconic/svg/circle-check.svg";
import LinkBroken from "open-iconic/svg/link-broken";
import LinkIntact from "open-iconic/svg/link-intact";
import LockLocked from "open-iconic/svg/lock-locked";
import LockUnlocked from "open-iconic/svg/lock-unlocked";
import Person from "open-iconic/svg/person";
import Pencil from "open-iconic/svg/pencil";
import Star from "open-iconic/svg/star";
import X from "open-iconic/svg/x";
import { computed } from "vue";
class IconData {
constructor(iconicIcon, dataAttributes) {
this.iconicIcon = iconicIcon;
this.dataAttributes = dataAttributes || {};
}
}
class SizeData {
constructor(bulmaIconClass, iconicSize, customClass) {
this.bulmaIconClass = bulmaIconClass;
this.iconicSize = iconicSize || null;
this.customIconClass = customClass || null;
}
}
const iconMap = {
'caret-bottom': CaretBottom,
'caret-top': CaretTop,
check: Check,
'circle-check': CircleCheck,
'link-broken': LinkBroken,
'link-intact': LinkIntact,
'lock-locked': LockLocked,
'lock-unlocked': LockUnlocked,
pencil: Pencil,
person: Person,
star: Star,
x: X
'caret-bottom': new IconData('caret', {'data-direction': 'bottom'}),
'caret-top': new IconData('caret', {'data-direction': 'top'}),
check: new IconData('check'),
'circle-check': new IconData('circle-check'),
'link-broken': new IconData('link', {'data-state': 'broken'}),
'link-intact': new IconData('link', {'data-state': 'intact'}),
'lock-locked': new IconData('lock', {'data-state': 'locked'}),
'lock-unlocked': new IconData('lock', {'data-state': 'unlocked'}),
menu: new IconData('menu'),
pencil: new IconData('pencil'),
person: new IconData('person'),
'question-mark': new IconData('question-mark'),
star: new IconData('star'),
'star-empty': new IconData('star-empty'),
warning: new IconData('warning'),
x: new IconData('x')
};
const sizeMap = {
sm: 'is-small',
md: '' ,
lg: 'is-medium',
xl: 'is-large'
xs: new SizeData('is-small', 'sm', 'is-xs'),
sm: new SizeData('is-small', 'sm'),
md: new SizeData('', 'sm', 'is-md'),
lg: new SizeData('is-medium', 'md'),
xl: new SizeData('is-large', 'md', 'is-xl')
};
export default {
emits: ["click"],
props: {
icon: {
validator: (i) => iconMap[i] !== undefined
@ -51,31 +61,29 @@
type: String,
validator: (s) => sizeMap[s] !== undefined,
default: 'md'
},
padding: {
type: String,
required: false,
default: null
}
},
computed: {
svgObj() {
return iconMap[this.icon];
},
svgAttributes() {
const attrs = {
class: this.size
};
setup(props) {
const iconData = computed(() => iconMap[props.icon]);
const sizeData = computed(() => sizeMap[props.size]);
const iconClasses = computed(() => [sizeData.value.bulmaIconClass, sizeData.value.customIconClass]);
const iconicSize = computed(() => sizeData.value.iconicSize);
const iconicIcon = computed(() => iconData.value.iconicIcon);
const iconicAttributes = computed(() => iconData.value.dataAttributes);
for (let a of ['viewBox', 'xmlns']) {
if (this.svgObj.attributes[a]) {
attrs[a] = this.svgObj.attributes[a];
}
}
return attrs;
},
svgContent() {
return this.svgObj.content;
},
sizeClass() {
return sizeMap[this.size];
return {
iconClasses,
iconData,
sizeData,
iconicAttributes,
iconicIcon,
iconicSize
}
}
}
@ -84,38 +92,41 @@
<style lang="scss" scoped>
.icon {
svg {
width: 100%;
height: 100%;
fill: currentColor;
&.sm {
width: 0.6em;
height: 0.6em;
}
&.md {
width: 1em;
height: 1em;
}
&.lg {
width: 1.33em;
height: 1.33em;
}
&.xl {
width: 2em;
height: 2em;
}
/*&.xl {*/
/*width: 3em;*/
/*height: 3em;*/
/*}*/
span.icon.is-xs {
svg.iconic {
width: 8px;
height: 8px;
}
}
span.icon.is-sm {
svg.iconic {
width: 12px;
height: 12px;
}
}
span.icon.is-md {
svg.iconic {
width: 16px;
height: 16px;
}
}
span.icon.is-lg {
svg.iconic {
width: 32px;
height: 32px;
}
}
span.icon.is-xl {
svg.iconic {
width: 48px;
height: 48px;
}
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<svg ref="svg" v-bind="svgAttributes" v-html="svgContent" :class="calculatedClasses"></svg>
</template>
<script>
import { computed, nextTick, onMounted, onUpdated, useTemplateRef } from "vue";
import Caret from "../iconic/svg/smart/caret";
import Check from "../iconic/svg/smart/check";
import CircleCheck from "../iconic/svg/smart/circle-check";
import Link from "../iconic/svg/smart/link";
import Lock from "../iconic/svg/smart/lock";
import Menu from "../iconic/svg/smart/menu";
import QuestionMark from "../iconic/svg/smart/question-mark.svg"
import Person from "../iconic/svg/smart/person";
import Pencil from "../iconic/svg/smart/pencil";
import Star from "../iconic/svg/smart/star";
import StarEmpty from "../iconic/svg/smart/star-empty";
import Warning from "../iconic/svg/smart/warning";
import X from "../iconic/svg/smart/x";
const APIS = {};
const LOADED_APIS = {};
window._Iconic = {
smartIconApis: APIS
};
let globalIdCounter = 0;
const iconMap = {
caret: Caret,
check: Check,
'circle-check': CircleCheck,
link: Link,
lock: Lock,
menu: Menu,
pencil: Pencil,
person: Person,
'question-mark': QuestionMark,
star: Star,
'star-empty': StarEmpty,
warning: Warning,
x: X
};
export default {
props: {
icon: {
type: String,
required: true,
validator: (i) => iconMap[i] !== undefined
},
size: {
type: String,
default: "lg",
validator: (s) => ["sm", "md", "lg"].indexOf(s) >= 0
},
iconSizeOverride: {
type: String,
validator: (s) => ["sm", "md", "lg", null].indexOf(s) >= 0
},
displaySizeOverride: {
type: String,
validator: (s) => ["sm", "md", "lg", null].indexOf(s) >= 0
}
},
setup(props) {
const svgElement = useTemplateRef("svg");
const svgData = computed(() => iconMap[props.icon]);
const svgAttributes = computed(() => svgData.value.attributes);
const svgName = computed(() => svgAttributes.value['data-icon']);
const svgContent = computed(() => {
let content = String(svgData.value.content);
for (let idRep of svgData.value.idReplacements) {
let newId = `__new_id_${globalIdCounter}`;
globalIdCounter += 1;
content = content.replace(new RegExp(idRep, "g"), newId);
}
return content;
});
const calculatedClasses = computed(() => {
const classes = (svgAttributes.value.class || "").split(" ");
classes.push(`iconic-${props.size}`);
if (props.iconSizeOverride) {
classes.push(`iconic-icon-${props.iconSizeOverride}`);
}
if (props.displaySizeOverride) {
classes.push(`iconic-size-${props.displaySizeOverride}`);
}
return classes;
});
function ensureSvgApi(name, scripts) {
if (!name) { return; }
if (LOADED_APIS[name] !== true) {
for (let sb of scripts) {
try {
new Function(sb)(window);
} catch (e) {
console.log(sb);
console.log(e);
}
}
LOADED_APIS[name] = true;
}
}
function setupSvgApi(name) {
const apis = APIS;
if (apis && apis[name]) {
const iconApi = apis[name](svgElement.value);
for (let func in iconApi) svgElement.value[func] = iconApi[func]
} else {
svgElement.value.update = function() {}
}
svgElement.value.update();
}
function updateScripts() {
ensureSvgApi(svgName.value, svgData.value.scriptBlocks);
setupSvgApi(svgName.value);
}
onMounted(() => {
updateScripts();
});
onUpdated(() => {
updateScripts();
});
return {
svgData,
svgAttributes,
svgName,
svgContent,
calculatedClasses
};
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div class="app-loading">
Imagine I'm a spinner...
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.app-loading {
position: absolute;
display: none;
}
</style>

View File

@ -1,68 +1,60 @@
<template>
<div ref="container">
<div ref="modal" :class="['popup', 'modal', { 'is-wide': wide, 'is-active': open && error === null }]">
<Teleport to="body">
<div :class="['modal', { 'is-wide': wide, 'is-active': open && error === null }]">
<div class="modal-background" @click="close"></div>
<div class="modal-card">
<header class="modal-card-head">
<slot name="title">
<p class="modal-card-title">{{ title }}</p>
<app-icon icon="x" aria-label="close" @click="close"></app-icon>
<app-icon class="close-button" icon="x" aria-label="close" @click="close"></app-icon>
</slot>
</header>
<section class="modal-card-body">
<slot></slot>
</section>
<footer class="modal-card-foot">
<slot name="footer">
</slot>
</footer>
</div>
</div>
</div>
</Teleport>
</template>
<script>
<script setup>
import { mapState } from "vuex";
import { computed } from "vue";
import { useAppConfigStore } from "../stores/appConfig";
export default {
props: {
open: {
type: Boolean,
default: false
},
title: String,
wide: {
type: Boolean,
default: false
}
const emit = defineEmits(["dismiss"]);
const props = defineProps({
open: {
type: Boolean,
default: false
},
mounted() {
document.body.appendChild(this.$refs.modal);
},
beforeDestroy() {
this.$refs.container.appendChild(this.$refs.modal);
},
computed: {
...mapState([
'error'
])
},
methods: {
close() {
this.$emit("dismiss");
}
},
components: {
title: String,
wide: {
type: Boolean,
default: false
}
});
const appConfig = useAppConfigStore();
const error = computed(() => appConfig.error);
function close() {
emit("dismiss");
}
</script>
<style lang="scss" scoped>
.close-button {
cursor: pointer;
}
</style>

View File

@ -15,10 +15,11 @@
<div class="navbar-start">
<a class="navbar-item" v-if="updateAvailable" href="#" @click.prevent="updateApp">UPDATE AVAILABLE!</a>
<router-link to="/" class="navbar-item">Recipes</router-link>
<router-link to="/ingredients" class="navbar-item">Ingredients</router-link>
<router-link to="/foods" class="navbar-item">Ingredients</router-link>
<router-link to="/calculator" class="navbar-item">Calculator</router-link>
<router-link v-if="isLoggedIn" to="/logs" class="navbar-item">Log</router-link>
<router-link v-if="isLoggedIn" to="/notes" class="navbar-item">Notes</router-link>
<router-link v-if="isLoggedIn" to="/tasks" class="navbar-item">Tasks</router-link>
<router-link to="/about" class="navbar-item">About</router-link>
<router-link v-if="isAdmin" to="/admin/users" class="navbar-item">Admin</router-link>
@ -50,46 +51,27 @@
</nav>
</template>
<script>
<script setup>
import { ref, watch } from "vue";
import UserLogin from "./UserLogin";
import { mapState } from "vuex";
import { storeToRefs } from "pinia";
import { useAppConfigStore } from "../stores/appConfig";
import { swUpdate } from "../lib/ServiceWorker";
import { useRoute } from "vue-router";
export default {
data() {
return {
menuActive: false
};
},
const appConfig = useAppConfigStore();
const menuActive = ref(false);
const route = useRoute();
const { isAdmin, isLoggedIn, updateAvailable, user } = storeToRefs(appConfig);
computed: {
...mapState([
'route',
'user',
'updateAvailable'
])
},
methods: {
updateApp() {
swUpdate();
}
},
watch: {
route() {
this.menuActive = false;
},
user() {
this.menuActive = false;
}
},
components: {
UserLogin
}
function updateApp() {
swUpdate();
}
watch(
() => [route, appConfig.user],
() => menuActive.value = false
);
</script>

View File

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

View File

@ -0,0 +1,69 @@
<template>
<div class="progress-bar" :style="progressStyle"></div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { useAppConfigStore } from "../stores/appConfig";
import TWEEN from '@tweenjs/tween.js';
import { useGlobalTweenGroup } from "../lib/useGlobalTweenGroup";
const appConfig = useAppConfigStore();
const showProgress = ref(false);
const loadingPercent = ref(0);
let animation = null;
const progressStyle = computed(() => {
return {
opacity: showProgress.value ? "1" : "0",
width: `${loadingPercent.value}%`,
height: "4px"
};
});
watch(() => appConfig.isLoading, val => {
if (val) {
start();
} else {
stop();
}
});
function start() {
if (!animation) {
showProgress.value = true;
animation = new TWEEN.Tween({ percent: 0 }, useGlobalTweenGroup())
.to({ percent: 90 })
.easing(TWEEN.Easing.Quartic.Out)
.duration(3000)
.onUpdate(({ percent }) => { loadingPercent.value = percent; })
.onComplete(({ percent }) => {})
.start();
}
}
function stop() {
if (animation) {
showProgress.value = false;
animation.stop();
animation = null;
}
}
</script>
<style lang="scss" scoped>
@use "bulma/sass/utilities" as bulma;
.progress-bar {
position: fixed;
top: 0;
left: 0;
z-index: 999999;
background-color: bulma.$blue;
transition: width 0.1s, opacity 0.3s;
}
</style>

View File

@ -1,111 +1,99 @@
<template>
<span ref="wrapper" class="rating" @click="handleClick" @mousemove="handleMousemove" @mouseleave="handleMouseleave">
<span class="set empty-set">
<app-icon v-for="i in starCount" :key="i" icon="star"></app-icon>
<app-iconic-icon v-for="i in starCount" :key="i" icon="star-empty" size="md"></app-iconic-icon>
</span>
<span class="set filled-set" :style="filledStyle">
<app-icon v-for="i in starCount" :key="i" icon="star"></app-icon>
<app-iconic-icon v-for="i in starCount" :key="i" icon="star" size="md"></app-iconic-icon>
</span>
</span>
</template>
<script>
<script setup>
import { computed, ref, useTemplateRef } from "vue";
export default {
props: {
starCount: {
required: false,
type: Number,
default: 5
},
const emit = defineEmits(["update:modelValue"]);
readonly: {
required: false,
type: Boolean,
default: false
},
const props = defineProps({
starCount: {
required: false,
type: Number,
default: 5
},
step: {
required: false,
type: Number,
default: 0.5
},
readonly: {
required: false,
type: Boolean,
default: false
},
value: {
required: false,
type: Number,
default: 0
}
},
step: {
required: false,
type: Number,
default: 0.5
},
data() {
return {
temporaryValue: null
};
},
computed: {
ratingPercent() {
return ((this.value || 0) / this.starCount) * 100.0;
},
temporaryPercent() {
if (this.temporaryValue !== null) {
return (this.temporaryValue / this.starCount) * 100.0;
} else {
return null;
}
},
filledStyle() {
const width = this.temporaryPercent || this.ratingPercent;
return {
width: width + "%"
};
}
},
methods: {
handleClick(evt) {
if (this.temporaryValue !== null) {
this.$emit("input", this.temporaryValue);
}
},
handleMousemove(evt) {
if (this.readonly) {
return;
}
const wrapperBox = this.$refs.wrapper.getBoundingClientRect();
const wrapperWidth = wrapperBox.right - wrapperBox.left;
const mousePosition = evt.clientX;
if (mousePosition > wrapperBox.left && mousePosition < wrapperBox.right) {
const filledRatio = ((mousePosition - wrapperBox.left) / wrapperWidth);
const totalSteps = this.starCount / this.step;
const filledSteps = Math.round(totalSteps * filledRatio);
this.temporaryValue = filledSteps * this.step;
}
},
handleMouseleave(evt) {
this.temporaryValue = null;
}
},
components: {
}
modelValue: {
required: false,
type: Number,
default: 0
}
});
const temporaryValue = ref(null);
const ratingPercent = computed(() => ((props.modelValue || 0) / props.starCount) * 100.0);
const wrapperEl = useTemplateRef("wrapper");
const temporaryPercent = computed(() => {
if (temporaryValue.value !== null) {
return (temporaryValue.value / props.starCount) * 100.0;
} else {
return null;
}
});
const filledStyle = computed(() => {
const width = temporaryPercent.value || ratingPercent.value;
return {
width: width + "%"
};
});
function handleClick(evt) {
if (temporaryValue.value !== null) {
emit("update:modelValue", temporaryValue.value);
}
}
function handleMousemove(evt) {
if (props.readonly) {
return;
}
const wrapperBox = wrapperEl.value.getBoundingClientRect();
const wrapperWidth = wrapperBox.right - wrapperBox.left;
const mousePosition = evt.clientX;
if (mousePosition > wrapperBox.left && mousePosition < wrapperBox.right) {
const filledRatio = ((mousePosition - wrapperBox.left) / wrapperWidth);
const totalSteps = props.starCount / props.step;
const filledSteps = Math.round(totalSteps * filledRatio);
temporaryValue.value = filledSteps * props.step;
}
}
function handleMouseleave(evt) {
temporaryValue.value = null;
}
</script>
<style lang="scss" scoped>
@import "../styles/variables";
@use "bulma/sass/utilities" as bulma;
span.rating {
position: relative;
@ -113,6 +101,11 @@
.set {
white-space: nowrap;
svg.iconic {
width: 1.5em;
height: 1.5em;
}
}
.empty-set {
@ -120,7 +113,7 @@
}
.filled-set {
color: $yellow;
color: bulma.$yellow;
position: absolute;
top: 0;
left: 0;

View File

@ -0,0 +1,51 @@
<template>
<div class="field">
<div class="control">
<input type="text" class="input" :placeholder="placeholder" :value="text === null ? '' : text" @input="userUpdateText($event.target.value)">
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import debounce from "lodash/debounce";
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
placeholder: {
required: false,
type: String,
default: ""
},
modelValue: {
required: false,
type: String,
default: ""
}
});
const text = ref(null);
const triggerInput = debounce(function() {
emit("update:modelValue", text.value);
},
250,
{ leading: false, trailing: true })
function userUpdateText(newText) {
if (text.value !== newText) {
text.value = newText;
triggerInput();
}
}
function propUpdateText(newText) {
if (text.value === null && text.value !== newText) {
text.value = newText;
}
}
</script>

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
<template>
<div>
<h1 class="title">{{action}} {{ingredient.name || "[Unnamed Ingredient]"}}</h1>
<h1 class="title">{{action}} {{food.name || "[Unnamed Food]"}}</h1>
<app-validation-errors :errors="validationErrors"></app-validation-errors>
<div class="field">
<label class="label is-small-mobile">Name</label>
<div class="control">
<input type="text" class="input is-small-mobile" v-model="ingredient.name">
<input type="text" class="input is-small-mobile" v-model="food.name">
</div>
</div>
@ -15,16 +15,17 @@
<label class="label is-small-mobile">Nutrient Databank Number</label>
<div class="field has-addons">
<div class="control">
<button type="button" class="button" :class="{'is-primary': hasNdbn}"><app-icon :icon="hasNdbn ? 'link-intact' : 'link-broken'" size="sm"></app-icon><span>{{ingredient.ndbn}}</span></button>
<button type="button" class="button" :class="{'is-primary': hasNdbn}"><app-icon :icon="hasNdbn ? 'link-intact' : 'link-broken'" size="sm"></app-icon><span>{{food.ndbn}}</span></button>
</div>
<div class="control is-expanded">
<app-autocomplete
:inputClass="'is-small-mobile'"
ref="autocomplete"
v-model="ingredient.usda_food_name"
v-model="food.usda_food_name"
:minLength="2"
valueAttribute="name"
labelAttribute="description"
key-attribute="ndbn"
placeholder=""
@optionSelected="searchItemSelected"
:onGetOptions="updateSearchItems"
@ -39,14 +40,14 @@
<div class="field">
<label class="label is-small-mobile">Density</label>
<div class="control">
<input type="text" class="input is-small-mobile" v-model="ingredient.density">
<input type="text" class="input is-small-mobile" v-model="food.density">
</div>
</div>
<div class="field">
<label class="label is-small-mobile">Notes</label>
<div class="control">
<textarea type="text" class="textarea is-small-mobile" v-model="ingredient.notes"></textarea>
<textarea type="text" class="textarea is-small-mobile" v-model="food.notes"></textarea>
</div>
</div>
@ -61,12 +62,15 @@
<button class="button" type="button" @click="addUnit">Add Unit</button>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Grams</th>
<th></th>
</tr>
<tr v-for="unit in visibleIngredientUnits" :key="unit.id">
</thead>
<tbody>
<tr v-for="unit in visibleFoodUnits" :key="unit.id">
<td>
<div class="control">
<input type="text" class="input is-small-mobile" v-model="unit.name">
@ -81,6 +85,7 @@
<button type="button" class="button is-danger" @click="removeUnit(unit)">X</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -95,14 +100,18 @@
<div class="message-body">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Grams</th>
</tr>
<tr v-for="unit in ingredient.ndbn_units">
</thead>
<tbody>
<tr v-for="unit in food.ndbn_units">
<td>{{unit.description}}</td>
<td>{{unit.gram_weight}}</td>
</tr>
</tbody>
</table>
</div>
@ -121,7 +130,7 @@
<label class="label is-small-mobile">{{nutrient.label}}</label>
<div class="field has-addons">
<div class="control is-expanded">
<input type="text" class="input is-small-mobile" :disabled="hasNdbn" v-model="ingredient[name]">
<input type="text" class="input is-small-mobile" :disabled="hasNdbn" v-model="food[name]">
</div>
<div class="control">
<button type="button" tabindex="-1" class="unit-label button is-static is-small-mobile">{{nutrient.unit}}</button>
@ -135,121 +144,82 @@
</div>
</template>
<script>
<script setup>
import { computed } from "vue";
import api from "../lib/Api";
import { mapState } from "pinia";
import { useNutrientStore } from "../stores/nutrient";
import { useLoadResource } from "../lib/useLoadResource";
export default {
props: {
ingredient: {
required: true,
type: Object
},
validationErrors: {
required: false,
type: Object,
default: {}
},
action: {
required: false,
type: String,
default: "Editing"
}
const nutrientStore = useNutrientStore();
const nutrients = computed(() => nutrientStore.nutrientList);
const { loadResource } = useLoadResource();
const props = defineProps({
food: {
required: true,
type: Object
},
data() {
return {
nutrients: {
kcal: { label: "Calories", unit: "kcal" },
protein: { label: "Protein", unit: "g" },
lipids: { label: "Fat", unit: "g" },
carbohydrates: { label: "Carbohydrates", unit: "g" },
water: { label: "Water", unit: "g" },
sugar: { label: "Sugar", unit: "g" },
fiber: { label: "Fiber", unit: "g" },
cholesterol: { label: "Cholesterol", unit: "mg" },
sodium: { label: "Sodium", unit: "mg" },
calcium: { label: "Calcium", unit: "mg" },
iron: { label: "Iron", unit: "mg" },
magnesium: { label: "Magnesium", unit: "mg" },
phosphorus: { label: "Phosphorus", unit: "mg" },
potassium: { label: "Potassium", unit: "mg" },
zinc: { label: "Zinc", unit: "mg" },
copper: { label: "Copper", unit: "mg" },
manganese: { label: "Manganese", unit: "mg" },
vit_a: { label: "Vitamin A", unit: "μg" },
vit_b6: { label: "Vitamin B6", unit: "mg" },
vit_b12: { label: "Vitamin B12", unit: "μg" },
vit_c: { label: "Vitamin C", unit: "mg" },
vit_d: { label: "Vitamin D", unit: "μg" },
vit_e: { label: "Vitamin E", unit: "mg" },
vit_k: { label: "Vitamin K", unit: "μg" },
ash: { label: "ash", unit: "g" }
}
};
validationErrors: {
required: false,
type: Object,
default: {}
},
computed: {
visibleIngredientUnits() {
return this.ingredient.ingredient_units.filter(iu => iu._destroy !== true);
},
hasNdbn() {
return this.ingredient.ndbn !== null;
}
},
methods: {
addUnit() {
this.ingredient.ingredient_units.push({
id: null,
name: null,
gram_weight: null
});
},
removeUnit(unit) {
if (unit.id) {
unit._destroy = true;
} else {
const idx = this.ingredient.ingredient_units.findIndex(i => i === unit);
this.ingredient.ingredient_units.splice(idx, 1);
}
},
removeNdbn() {
this.ingredient.ndbn = null;
this.ingredient.usda_food_name = null;
this.ingredient.ndbn_units = [];
},
updateSearchItems(text) {
return api.getUsdaFoodSearch(text)
.then(data => data.map(f => {
return {
name: f.name,
ndbn: f.ndbn,
description: ["#", f.ndbn, ", Cal:", f.kcal, ", Carbs:", f.carbohydrates, ", Fat:", f.lipid, ", Protein:", f.protein].join("")
}
}));
},
searchItemSelected(food) {
this.ingredient.ndbn = food.ndbn;
this.ingredient.usda_food_name = food.name;
this.ingredient.ndbn_units = [];
this.loadResource(
api.postIngredientSelectNdbn(this.ingredient)
.then(i => Object.assign(this.ingredient, i))
);
},
},
components: {
action: {
required: false,
type: String,
default: "Editing"
}
});
const visibleFoodUnits = computed(() => props.food.food_units.filter(iu => iu._destroy !== true));
const hasNdbn = computed(() => props.food.ndbn !== null);
function addUnit() {
props.food.food_units.push({
id: null,
name: null,
gram_weight: null
});
}
function removeUnit(unit) {
if (unit.id) {
unit._destroy = true;
} else {
const idx = props.food.food_units.findIndex(i => i === unit);
props.food.food_units.splice(idx, 1);
}
}
function removeNdbn() {
props.food.ndbn = null;
props.food.usda_food_name = null;
props.food.ndbn_units = [];
}
function updateSearchItems(text) {
return api.getUsdaFoodSearch(text)
.then(data => data.map(f => {
return {
name: f.name,
ndbn: f.ndbn,
description: ["#", f.ndbn, ", Cal:", f.kcal, ", Carbs:", f.carbohydrates, ", Fat:", f.lipid, ", Protein:", f.protein].join("")
}
}));
}
function searchItemSelected(food) {
props.food.ndbn = food.ndbn;
props.food.usda_food_name = food.name;
props.food.ndbn_units = [];
loadResource(
api.postIngredientSelectNdbn(props.food)
.then(i => Object.assign(props.food, i))
);
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<div>
<h3 class="title">
{{food.name}}
</h3>
<div class="message" v-if="food.ndbn">
<div class="message-header">
<span>USDA NDBN #{{ food.ndbn }}</span>
</div>
<div class="message-body">
<a :href="'https://ndb.nal.usda.gov/ndb/foods/show/' + food.ndbn">USDA DB Entry</a>
</div>
</div>
<div class="message">
<div class="message-header">
Custom Units
</div>
<div class="message-body">
<ul>
<li v-for="fu in food.food_units" :key="fu.id">
{{fu.name}}: {{fu.gram_weight}} grams
</li>
</ul>
</div>
</div>
<div class="message">
<div class="message-header">
Nutrition per 100 grams
</div>
<div class="message-body">
<div class="columns is-mobile is-multiline">
<div v-for="(nutrient, name) in nutrients" :key="name" class="column is-half-mobile is-one-third-tablet">
<label class="label is-small-mobile">{{nutrient.label}}</label>
<div class="field has-addons">
<div class="control is-expanded">
<input type="text" class="input is-small-mobile" disabled="true" v-model="food[name]">
</div>
<div class="control">
<button type="button" tabindex="-1" class="unit-label button is-static is-small-mobile">{{nutrient.unit}}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useNutrientStore } from "../stores/nutrient";
const props = defineProps({
food: {
required: true,
type: Object
}
});
const nutrientStore = useNutrientStore();
const nutrients = computed(() => nutrientStore.nutrientList);
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,21 +0,0 @@
<template>
<div>
{{ingredient.name}}
</div>
</template>
<script>
export default {
props: {
ingredient: {
required: true,
type: Object
}
}
}
</script>
<style lang="scss" scoped>
</style>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="recipe-edit">
<template v-if="!forLogging">
<div class="columns">
@ -35,13 +35,11 @@
</div>
</div>
<h3 class="title is-4">Ingredients</h3>
<recipe-edit-ingredient-editor :ingredients="recipe.ingredients"></recipe-edit-ingredient-editor>
<br>
<div class="field">
<label class="label title is-4">Directions</label>
<label class="label title is-4">Directions <button @click="isDescriptionHelpOpen = true" class="button is-small is-link"><app-icon icon="question-mark"></app-icon></button></label>
<div class="control columns">
<div class="column">
<textarea ref="step_text_area" class="textarea directions-input" v-model="recipe.step_text"></textarea>
@ -53,74 +51,214 @@
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="recipe.is_ingredient" />
Is Ingredient
</label>
</div>
<app-modal title="Markdown Help" :open="isDescriptionHelpOpen" @dismiss="isDescriptionHelpOpen = false">
<p>
The description editor uses <a href="https://www.markdownguide.org/cheat-sheet/">Markdown</a>. Follow the link for a full
description of the syntax, but below is a quick reference.
</p>
<table class="table">
<thead>
<tr>
<th>Style</th>
<th>Syntax</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Heading</td>
<td>
<pre>
# Biggest Heading
## Smaller Heading
###### Smallest Heading
</pre>
</td>
<td class="content">
<h1>Biggest Heading</h1>
<h2>Smaller Heading</h2>
<h6>Smallest Heading</h6>
</td>
</tr>
<tr>
<td>Numbered Lists</td>
<td>
<pre>
1. First Item
1. Second Item
1. subitem A
1. subitem B
1. Thrid Item
</pre>
</td>
<td class="content">
<ol>
<li>First Item</li>
<li>Second Item
<ol>
<li>subitem A</li>
<li>subitem B</li>
</ol>
</li>
<li>Thrid Item</li>
</ol>
</td>
</tr>
<tr>
<td>Lists</td>
<td>
<pre>
* First Item
* Second Item
* subitem A
* subitem B
* Third Item
</pre>
</td>
<td class="content">
<ul>
<li>First Item</li>
<li>Second Item
<ul>
<li>subitem A</li>
<li>subitem B</li>
</ul></li>
<li>Third Item</li>
</ul>
</td>
</tr>
<tr>
<td>Basic Styles</td>
<td>
<pre>
*italics*
**bold**
***bold italics***
_underline_
==highlight==
</pre>
</td>
<td class="content">
<p>
<em>italics</em><br>
<strong>bold</strong><br>
<strong><em>bold italics</em></strong><br>
<u>underline</u><br>
<mark>highlight</mark>
</p>
</td>
</tr>
</tbody>
</table>
<h3 class="title is-3">Basic Example</h3>
<h5 class="subtitle is=3">Input</h5>
<pre>
## For the dough
1. Mix dry ingredients
1. Fold in egg whites
1. Sprinkle on sardines
## For the sauce
1. Blend clams ==thoroughly==
1. Melt beef lard and add clam slurry
### Optional (Toppings)
* Raw onion
* Sliced hard boiled eggs
</pre>
<h5 class="subtitle is=3">Output</h5>
<div class="content box">
<h2>For the dough</h2>
<ol>
<li>Mix dry ingredients</li>
<li>Fold in egg whites</li>
<li>Sprinkle on sardines</li>
</ol>
<h2>For the sauce</h2>
<ol>
<li>Blend clams <mark>thoroughly</mark></li>
<li>Melt beef lard and add clam slurry</li>
</ol>
<h3>Optional (Toppings)</h3>
<ul>
<li>Raw onion</li>
<li>Sliced hard boiled eggs</li>
</ul>
</div>
</app-modal>
</div>
</template>
<script>
<script setup>
import autosize from "autosize";
import { computed, ref, useTemplateRef, watch } from "vue";
import { useAutosize } from "../lib/useAutosize";
import debounce from "lodash/debounce";
import api from "../lib/Api";
import RecipeEditIngredientEditor from "./RecipeEditIngredientEditor";
export default {
props: {
recipe: {
required: true,
type: Object
},
forLogging: {
required: false,
type: Boolean,
default: false
}
const props = defineProps({
recipe: {
required: true,
type: Object
},
data() {
return {
stepPreviewCache: null
};
},
computed: {
stepPreview() {
if (this.stepPreviewCache === null) {
return this.recipe.rendered_steps;
} else {
return this.stepPreviewCache;
}
}
},
methods: {
updatePreview: debounce(function() {
api.postPreviewSteps(this.recipe.step_text)
.then(data => this.stepPreviewCache = data.rendered_steps)
.catch(err => this.stepPreviewCache = "?? Error ??");
}, 750)
},
watch: {
'recipe.step_text': function() {
this.updatePreview();
}
},
mounted() {
//autosize(this.$refs.step_text_area);
},
components: {
RecipeEditIngredientEditor
forLogging: {
required: false,
type: Boolean,
default: false
}
}
});
const stepTextArea = useTemplateRef("step_text_area");
const stepPreviewCache = ref(null);
const isDescriptionHelpOpen = ref(false);
useAutosize(stepTextArea);
const stepPreview = computed(() => {
if (stepPreviewCache.value === null) {
return props.recipe.rendered_steps;
} else {
return stepPreviewCache.value;
}
});
const updatePreview = debounce(function() {
api.postPreviewSteps(props.recipe.step_text)
.then(data => stepPreviewCache.value = data.rendered_steps)
.catch(err => stepPreviewCache.value = "?? Error ??");
}, 750);
watch(
() => props.recipe.step_text,
() => updatePreview()
);
</script>
<style lang="scss" scoped>
.recipe-edit {
margin-bottom: 1rem;
}
.directions-input {
height: 100%;
}

View File

@ -1,6 +1,10 @@
<template>
<div>
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
<h3 class="title is-4">
Ingredients
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
</h3>
<app-modal wide :open="isBulkEditing" title="Edit Ingredients" @dismiss="cancelBulkEditing">
<div class="columns">
<div class="column is-half bulk-input">
@ -8,18 +12,22 @@
</div>
<div class="column is-half">
<table class="table is-bordered is-narrow is-size-7">
<thead>
<tr>
<th>#</th>
<th>Unit</th>
<th>Name</th>
<th>Prep</th>
</tr>
</thead>
<tbody>
<tr v-for="i in bulkIngredientPreview">
<td>{{i.quantity}}</td>
<td>{{i.units}}</td>
<td>{{i.name}}</td>
<td>{{i.preparation}}</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -28,175 +36,162 @@
</app-modal>
<div>
<recipe-edit-ingredient-item v-for="(i, idx) in visibleIngredients" :key="i.id" :ingredient="i" :show-labels="idx === 0 || isMobile" @deleteIngredient="deleteIngredient"></recipe-edit-ingredient-item>
<recipe-edit-ingredient-item v-for="(i, idx) in visibleIngredients" :key="i.id" :ingredient="i" :show-labels="idx === 0 || isMobile" @deleteFood="deleteFood"></recipe-edit-ingredient-item>
</div>
<button type="button" class="button is-primary" @click="addIngredient">Add Ingredient</button>
</div>
</template>
<script>
<script setup>
import { computed, ref } from "vue";
import { useMediaQueryStore } from "../stores/mediaQuery";
import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
import { mapState } from "vuex";
const mediaQueryStore = useMediaQueryStore();
export default {
props: {
ingredients: {
required: true,
type: Array
}
},
data() {
return {
isBulkEditing: false,
bulkEditText: null
};
},
computed: {
...mapState({
isMobile: state => state.mediaQueries.mobile
}),
bulkIngredientPreview() {
if (this.bulkEditText === null) {
return [];
}
const regex = /^\s*(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,|]+?|.+\|)(?:,\s*([^|]*?))?(?:\s*\[(\d+)\]\s*)?$/i;
const magicFunc = function(str) {
if (str === "-") {
return "";
} else {
return str;
}
};
const parsed = [];
const lines = this.bulkEditText.replace("\r", "").split("\n");
for (let line of lines) {
if (line.length === 0) { continue; }
const match = line.match(regex);
if (match) {
const matchedName = match[3].replace(/\|\s*$/, "");
let item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(matchedName), preparation: magicFunc(match[4]), id: match[5] || null};
parsed.push(item);
} else {
parsed.push(null);
}
}
return parsed;
},
visibleIngredients() {
return this.ingredients.filter(i => i._destroy !== true);
}
},
methods: {
createIngredient() {
return {
id: null,
quantity: null,
units: null,
name: null,
preparation: null,
ingredient_id: null,
sort_order: Math.max([0].concat(this.ingredients.map(i => i.sort_order))) + 5
};
},
addIngredient() {
this.ingredients.push(this.createIngredient());
},
deleteIngredient(ingredient) {
if (ingredient.id) {
ingredient._destroy = true;
} else {
const idx = this.ingredients.findIndex(i => i === ingredient);
this.ingredients.splice(idx, 1);
}
},
bulkEditIngredients() {
this.isBulkEditing = true;
let text = [];
for (let item of this.visibleIngredients) {
text.push(
item.quantity + " " +
(item.units || "-") + " " +
(item.name.indexOf(",") >= 0 ? item.name + "|" : item.name) +
(item.preparation ? (", " + item.preparation) : "") +
(item.id ? (" [" + item.id + "]") : "")
);
}
this.bulkEditText = text.join("\n");
},
cancelBulkEditing() {
this.isBulkEditing = false;
},
saveBulkEditing() {
const parsed = this.bulkIngredientPreview.filter(i => i !== null);
const existing = [...this.ingredients];
const newList = [];
for (let parsedIngredient of parsed) {
let newIngredient = null;
if (parsedIngredient.id !== null) {
let intId = parseInt(parsedIngredient.id);
let exIdx = existing.findIndex(i => i.id === intId);
if (exIdx >= 0) {
let ex = existing[exIdx];
if (ex.name === parsedIngredient.name) {
newIngredient = ex;
existing.splice(exIdx, 1);
}
}
}
if (newIngredient === null) {
newIngredient = this.createIngredient();
}
newIngredient.quantity = parsedIngredient.quantity;
newIngredient.units = parsedIngredient.units;
newIngredient.name = parsedIngredient.name;
newIngredient.preparation = parsedIngredient.preparation;
newList.push(newIngredient);
}
for (let oldExisting of existing.filter(i => i.id !== null)) {
newList.push({id: oldExisting.id, _destroy: true});
}
this.ingredients.splice(0);
let sortIdx = 0;
for (let n of newList) {
n.sort_order = sortIdx++;
this.ingredients.push(n);
}
this.isBulkEditing = false;
}
},
components: {
RecipeEditIngredientItem
const props = defineProps({
ingredients: {
required: true,
type: Array
}
});
const isBulkEditing = ref(false);
const bulkEditText = ref(null);
const isMobile = computed(() => mediaQueryStore.mobile);
const visibleIngredients = computed(() => props.ingredients.filter(i => i._destroy !== true));
const bulkIngredientPreview = computed(() => {
if (bulkEditText.value === null || bulkEditText.value === "") {
return [];
}
const regex = /^\s*(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,|]+?|.+\|)(?:,\s*([^|]*?))?(?:\s*\[(\d+)\]\s*)?$/i;
const magicFunc = function(str) {
if (str === "-") {
return "";
} else {
return str;
}
};
const parsed = [];
const lines = bulkEditText.value.replace("\r", "").split("\n");
for (let line of lines) {
if (line.length === 0) { continue; }
const match = line.match(regex);
if (match) {
const matchedName = match[3].replace(/\|\s*$/, "");
let item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(matchedName), preparation: magicFunc(match[4]), id: match[5] || null};
parsed.push(item);
} else {
parsed.push(null);
}
}
return parsed;
});
function createIngredient() {
const sort_orders = props.ingredients.map(i => i.sort_order);
sort_orders.push(0);
const next_sort_order = Math.max(...sort_orders) + 5;
return {
id: null,
quantity: null,
units: null,
name: null,
preparation: null,
ingredient_id: null,
sort_order: next_sort_order
};
}
function addIngredient() {
props.ingredients.push(createIngredient());
}
function deleteFood(food) {
if (food.id) {
food._destroy = true;
} else {
const idx = props.ingredients.findIndex(i => i === food);
props.ingredients.splice(idx, 1);
}
}
function bulkEditIngredients() {
isBulkEditing.value = true;
let text = [];
for (let item of visibleIngredients.value) {
text.push(
item.quantity + " " +
(item.units || "-") + " " +
(item.name.indexOf(",") >= 0 ? item.name + "|" : item.name) +
(item.preparation ? (", " + item.preparation) : "") +
(item.id ? (" [" + item.id + "]") : "")
);
}
bulkEditText.value = text.join("\n");
}
function cancelBulkEditing() {
isBulkEditing.value = false;
}
function saveBulkEditing() {
const parsed = bulkIngredientPreview.value.filter(i => i !== null);
const existing = [...props.ingredients];
const newList = [];
for (let parsedIngredient of parsed) {
let newIngredient = null;
if (parsedIngredient.id !== null) {
let intId = parseInt(parsedIngredient.id);
let exIdx = existing.findIndex(i => i.id === intId);
if (exIdx >= 0) {
let ex = existing[exIdx];
if (ex.name === parsedIngredient.name) {
newIngredient = ex;
existing.splice(exIdx, 1);
}
}
}
if (newIngredient === null) {
newIngredient = createIngredient();
}
newIngredient.quantity = parsedIngredient.quantity;
newIngredient.units = parsedIngredient.units;
newIngredient.name = parsedIngredient.name;
newIngredient.preparation = parsedIngredient.preparation;
newList.push(newIngredient);
}
for (let oldExisting of existing.filter(i => i.id !== null)) {
newList.push({id: oldExisting.id, _destroy: true});
}
props.ingredients.splice(0);
let sortIdx = 0;
for (let n of newList) {
n.sort_order = sortIdx++;
props.ingredients.push(n);
}
isBulkEditing.value = false;
}
</script>

View File

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

View File

@ -37,6 +37,11 @@
<div class="message-header">
Ingredients
<button class="button is-small is-primary" type="button" @click="showConvertDialog = true">Convert</button>
<app-dropdown :open="addToTasksMenuOpen" label="Add to list" button-class="is-small is-primary" @open="addToTasksMenuOpen = true" @close="addToTasksMenuOpen = false">
<button class="button primary" v-for="tl in taskStore.taskLists" :key="tl.id" @click="addRecipeToList(tl)">
{{tl.name}}
</button>
</app-dropdown>
</div>
<div class="message-body content">
<ul v-if="recipe.ingredients.length > 0" v-click-strike>
@ -60,14 +65,18 @@
<div class="message-header" @click="showNutrition = !showNutrition">Nutrition Data</div>
<div class="message-body" v-show="showNutrition">
<table class="table">
<thead>
<tr>
<th>Item</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name">
<td>{{nutrient.label}}</td>
<td>{{nutrient.value}}</td>
<td>{{ roundValue(nutrient.value) }}</td>
</tr>
</tbody>
</table>
<h3 class="title is-5">Nutrition Calculation Warnings</h3>
@ -141,121 +150,122 @@
</div>
</template>
<script>
<script setup>
export default {
props: {
recipe: {
required: true,
type: Object
}
},
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import api from "../lib/Api";
import { useTaskStore } from "../stores/task";
data() {
return {
showNutrition: false,
showConvertDialog: false,
const taskStore = useTaskStore();
const router = useRouter();
scaleValue: '1',
systemConvertValue: "",
unitConvertValue: "",
const props = defineProps({
recipe: {
required: true,
type: Object
}
});
scaleOptions: [
'1/4',
'1/3',
'1/2',
'2/3',
'3/4',
'1',
'1 1/2',
'2',
'3',
'4'
]
};
},
const showNutrition = ref(false);
const showConvertDialog = ref(false);
const addToTasksMenuOpen = ref(false);
computed: {
timeDisplay() {
let a = this.formatMinutes(this.recipe.active_time);
const t = this.formatMinutes(this.recipe.total_time);
const scaleValue = ref('1');
const systemConvertValue = ref('');
const unitConvertValue = ref('');
if (a) {
a = ` (${a} active)`;
}
const scaleOptions = [
'1/4',
'1/3',
'1/2',
'2/3',
'3/4',
'1',
'1 1/2',
'2',
'3',
'4'
];
return t + a;
},
const timeDisplay = computed(() => {
let a = formatMinutes(props.recipe.active_time);
const t = formatMinutes(props.recipe.total_time);
sourceUrl() {
try {
return new URL(this.recipe.source);
} catch(err) {
return null;
}
},
if (a) {
a = ` (${a} active)`;
}
isSourceUrl() {
return this.sourceUrl !== null;
},
return t + a;
});
sourceText() {
if (this.isSourceUrl) {
return this.sourceUrl.host;
} else {
return this.source;
const sourceUrl = computed(() => {
try {
return new URL(props.recipe.source);
} catch(err) {
return null;
}
});
const isSourceUrl = computed(() => sourceUrl.value !== null);
const sourceText = computed(() => isSourceUrl.value ? sourceUrl.value.host : props.recipe.source);
watch(props.recipe, (r) => {
if (r) {
scaleValue.value = r.converted_scale || '1';
systemConvertValue.value = r.converted_system;
unitConvertValue.value = r.converted_unit;
}
}, { immediate: true });
onMounted(() => {
taskStore.ensureTaskLists();
});
function addRecipeToList(list) {
api.addRecipeToTaskList(list.id, props.recipe.id)
.then(() => {
taskStore.setCurrentTaskList(list);
router.push({name: 'task_lists'})
});
}
function convert() {
showConvertDialog.value = false;
router.push({name: 'recipe', query: { scale: scaleValue.value, system: systemConvertValue.value, unit: unitConvertValue.value }});
}
function roundValue(v) {
return parseFloat(v).toFixed(2);
}
function formatMinutes(min) {
if (min) {
const partUnits = [
{unit: "d", minutes: 60 * 24},
{unit: "h", minutes: 60},
{unit: "m", minutes: 1}
];
const parts = [];
let remaining = min;
for (let unit of partUnits) {
let val = Math.floor(remaining / unit.minutes);
remaining = remaining % unit.minutes;
if (val > 0) {
parts.push(`${val} ${unit.unit}`);
}
}
},
watch: {
recipe: {
handler: function(r) {
if (r) {
this.scaleValue = r.converted_scale || '1';
this.systemConvertValue = r.converted_system;
this.unitConvertValue = r.converted_unit;
}
},
immediate: true
}
},
methods: {
convert() {
this.showConvertDialog = false;
this.$router.push({name: 'recipe', query: { scale: this.scaleValue, system: this.systemConvertValue, unit: this.unitConvertValue }});
},
formatMinutes(min) {
if (min) {
const partUnits = [
{unit: "d", minutes: 60 * 24},
{unit: "h", minutes: 60},
{unit: "m", minutes: 1}
];
const parts = [];
let remaining = min;
for (let unit of partUnits) {
let val = Math.floor(remaining / unit.minutes);
remaining = remaining % unit.minutes;
if (val > 0) {
parts.push(`${val} ${unit.unit}`);
}
}
return parts.join(" ");
} else {
return "";
}
}
return parts.join(" ");
} else {
return "";
}
}
</script>
<style lang="scss" scoped>
</style>
</style>

View File

@ -0,0 +1,71 @@
<template>
<div class="columns task-item-edit">
<div class="field column">
<label class="label is-small">Name</label>
<div class="control">
<input class="input is-small" type="text" placeholder="Name" v-model="taskItem.name" @keydown="inputKeydown" ref="nameInput">
</div>
</div>
<div class="field column">
<label class="label is-small">Quantity</label>
<div class="control">
<input class="input is-small" type="text" placeholder="Qty" v-model="taskItem.quantity" @keydown="inputKeydown">
</div>
</div>
<div class="field column">
<div class="control">
<button class="button is-primary" @click="save">Add</button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, useTemplateRef } from "vue";
const emit = defineEmits(["save"]);
const props = defineProps({
taskItem: {
required: true,
type: Object
}
});
const nameElement = useTemplateRef("nameInput");
onMounted(() => focus());
function inputKeydown(evt) {
switch (evt.key) {
case "Enter":
evt.preventDefault();
save();
}
}
function save() {
emit("save", props.taskItem);
}
function focus() {
nameElement.value.focus();
}
defineExpose({
focus
});
</script>
<style lang="scss" scoped>
.task-item-edit {
}
</style>

View File

@ -0,0 +1,193 @@
<template>
<div>
<div class="panel">
<p class="panel-heading">
{{taskList.name}} ({{completedItemCount}} / {{taskList.task_items.length}})
</p>
<div class="panel-block">
<button class="button is-fullwidth is-primary" @click="toggleShowAddItem">{{ showAddItem ? 'Done' : 'New Item' }}</button>
</div>
<app-expand-transition>
<div class="panel-block" v-if="showAddItem">
<task-item-edit @save="save" :task-item="newItem" ref="itemEdit"></task-item-edit>
</div>
</app-expand-transition>
<transition-group tag="div" name="list-item-move">
<a v-for="i in taskItems" :key="i.id" @click="toggleItem(i)" class="panel-block">
<div class="check">
<app-icon v-if="i.completed" icon="check"></app-icon>
<span class="icon" v-else></span>
</div>
<span>{{ i.quantity }} {{ i.name }}</span>
</a>
</transition-group>
<app-expand-transition>
<div class="panel-block" v-if="uncompletedItemCount > 0">
<button class="button is-fullwidth is-link" @click="completeAllItems">
<span class="check">
<app-icon icon="check"></app-icon>
</span>
<span>Check All</span>
</button>
</div>
</app-expand-transition>
<app-expand-transition>
<div class="panel-block" v-if="completedItemCount > 0">
<button class="button is-fullwidth is-link" @click="unCompleteAllItems">
<span class="check">
<span class="icon"></span>
</span>
<span>Uncheck All</span>
</button>
</div>
</app-expand-transition>
<app-expand-transition>
<div class="panel-block" v-if="completedItemCount > 0">
<button class="button is-fullwidth is-link" @click="deleteCompletedItems">
<app-icon icon="x" class="is-text-danger"></app-icon>
<span>Clear Completed</span>
</button>
</div>
</app-expand-transition>
</div>
</div>
</template>
<script setup>
import { computed, ref, useTemplateRef } from "vue";
import * as Errors from '../lib/Errors';
import { useTaskStore } from "../stores/task";
import { useLoadResource } from "../lib/useLoadResource";
import TaskItemEdit from "./TaskItemEdit";
const { loadResource } = useLoadResource();
const taskStore = useTaskStore();
const itemEditElement = useTemplateRef("itemEdit");
const props = defineProps({
taskList: {
required: true,
type: Object
}
});
const showAddItem = ref(false);
const newItem = ref(null);
const newItemValidationErrors = ref({});
const completedTaskItems = computed(() => (props.taskList ? props.taskList.task_items : []).filter(i => i.completed));
const uncompletedTaskItems = computed(() => (props.taskList ? props.taskList.task_items : []).filter(i => !i.completed));
const completedItemCount = computed(() => completedTaskItems.value.length);
const uncompletedItemCount = computed(() => uncompletedTaskItems.value.length);
const taskItems = computed(() => uncompletedTaskItems.value.concat(completedTaskItems.value));
function newItemTemplate() {
return {
task_list_id: null,
name: '',
quantity: '',
completed: false
};
}
function save() {
newItem.value.task_list_id = props.taskList.id;
loadResource(
taskStore.createTaskItem(newItem.value)
.then(() => {
newItem.value = newItemTemplate();
itemEditElement.value.focus();
})
.catch(Errors.onlyFor(Errors.ApiValidationError, err => newItemValidationErrors.value = err.validationErrors()))
)
}
function toggleItem(i) {
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: [i],
completed: !i.completed
})
);
}
function toggleShowAddItem() {
newItem.value = newItemTemplate();
showAddItem.value = !showAddItem.value;
}
function completeAllItems() {
const toComplete = props.taskList.task_items.filter(i => !i.completed);
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: toComplete,
completed: true
})
)
}
function unCompleteAllItems() {
const toUnComplete = props.taskList.task_items.filter(i => i.completed);
loadResource(
taskStore.completeTaskItems({
taskList: props.taskList,
taskItems: toUnComplete,
completed: false
})
)
}
function deleteCompletedItems() {
loadResource(
taskStore.deleteTaskItems({
taskList: props.taskList,
taskItems: props.taskList.task_items.filter(i => i.completed)
})
);
}
</script>
<style lang="scss" scoped>
.columns {
margin-top: 0;
margin-bottom: 0;
}
.column {
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
}
.check {
display: inline-flex;
margin-right: 1.5rem;
.icon {
position: relative;
&:after {
content: '';
position: absolute;
top: -3px;
left: -3px;
bottom: -3px;
right: -3px;
border: 2px solid currentColor;
}
}
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="dropdown-item" @mouseover="hovering = true" @mouseleave="hovering = false" :class="{hovered: hovering, 'is-active': active}" @click="selectList">
<span>{{taskList.name}} ({{ taskList.task_items.length }})</span>
<button @click.stop="confirmingDelete = true" class="button is-small is-danger is-pulled-right"><app-icon icon="x" size="sm"></app-icon></button>
<div class="is-clearfix"></div>
<app-modal :open="confirmingDelete" :title="'Delete ' + taskList.name + '?'" @dismiss="confirmingDelete = false">
<button class="button is-danger" @click="deleteList">Confirm</button>
<button class="button is-primary" @click="confirmingDelete = false">Cancel</button>
</app-modal>
</div>
</template>
<script setup>
import { ref } from "vue";
const emit = defineEmits(["select", "delete"]);
const props = defineProps({
taskList: {
type: Object,
required: true
},
active: {
type: Boolean,
required: false,
default: false
}
});
const hovering = ref(false);
const confirmingDelete = ref(false);
function selectList() {
emit("select", props.taskList);
}
function deleteList() {
confirmingDelete.value = false;
emit("delete", props.taskList);
}
</script>
<style lang="scss" scoped>
@use "bulma/sass/utilities" as bulma;
@use 'sass:color';
div.dropdown-item {
cursor: pointer;
&.hovered {
color: bulma.$black;
background-color: bulma.$background;
}
&.is-active {
color: color.invert(bulma.$link);
background-color: bulma.$link;
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<div>
<app-validation-errors :errors="validationErrors"></app-validation-errors>
<label class="label is-small">Add New List</label>
<div class="field has-addons">
<div class="control">
<input class="input is-small" type="text" v-model="taskList.name" @keydown="nameKeydownHandler">
</div>
<div class="control">
<button type="button" class="button is-primary is-small" @click="save">Add</button>
</div>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(["save"]);
const props = defineProps({
taskList: {
required: true,
type: Object
},
validationErrors: {
required: false,
type: Object,
default: function() { return {}; }
}
});
function save() {
emit("save");
}
function nameKeydownHandler(evt) {
switch (evt.key) {
case "Enter":
evt.preventDefault();
save();
}
}
</script>

View File

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

View File

@ -3,11 +3,11 @@
<h1 class="title">About</h1>
<p>
A Recipe manager. Source available from my Git repository at <a href="https://source.elbert.us/dan/parsley">https://source.elbert.us</a>.
A Recipe manager. Source available at <a href="https://source.elbert.us/dan/parsley">https://source.elbert.us</a>.
</p>
<p>
Parsley is released under the MIT License. All code &copy; Dan Elbert 2018.
Parsley is released under the MIT License. All code &copy; Dan Elbert 2024.
</p>
<p>
@ -19,11 +19,7 @@
</div>
</template>
<script>
export default {
}
<script setup>
</script>

View File

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

View File

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

View File

@ -2,121 +2,102 @@
<div>
<h1 class="title">Calculator</h1>
<div v-for="err in errors" :key="err" class="notification is-warning">
{{err}}
</div>
<div class="box">
<div class="columns">
<app-text-field label="Input" v-model="input" class="column" :validation-error="inputErrors"></app-text-field>
<app-text-field label="Output Unit" v-model="outputUnit" class="column" :validation-error="outputUnitErrors"></app-text-field>
<div class="columns">
<div class="field column">
<label class="label">Input</label>
<div class="control">
<input class="input" type="text" placeholder="input" v-model="input">
</div>
</div>
<div class="field column">
<label class="label">Output Unit</label>
<div class="control">
<input class="input" type="text" placeholder="unit" v-model="outputUnit">
<div class="columns">
<div class="field column">
<label class="label">Ingredient</label>
<div class="control">
<app-autocomplete
:inputClass="{'is-success': ingredient !== null}"
ref="autocomplete"
v-model="ingredient_name"
:minLength="2"
valueAttribute="name"
labelAttribute="name"
placeholder=""
@optionSelected="searchItemSelected"
:onGetOptions="updateSearchItems"
>
</app-autocomplete>
</div>
</div>
<app-text-field label="Density" v-model="density" class="column" :disabled="ingredient !== null" :validation-error="densityErrors"></app-text-field>
</div>
<app-text-field label="Output" v-model="output" disabled></app-text-field>
</div>
<div class="columns">
<div class="field column">
<label class="label">Ingredient</label>
<div class="control">
<app-autocomplete
:inputClass="{'is-success': ingredient !== null}"
ref="autocomplete"
v-model="ingredient_name"
:minLength="2"
valueAttribute="name"
labelAttribute="name"
placeholder=""
@optionSelected="searchItemSelected"
:onGetOptions="updateSearchItems"
>
</app-autocomplete>
</div>
</div>
<div class="field column">
<label class="label">Density</label>
<div class="control">
<input class="input" type="text" placeholder="8.345 lb/gallon" v-model="density">
</div>
</div>
</div>
<div class="field">
<label class="label">Output</label>
<div class="control">
<input class="input" type="text" disabled="disabled" v-model="output">
</div>
</div>
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from "vue";
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
input: '',
outputUnit: '',
ingredient_name: '',
ingredient: null,
density: '',
output: '',
errors: []
};
},
const { loadResource } = useLoadResource();
methods: {
updateSearchItems(text) {
return api.getSearchIngredients(text);
},
const input = ref("");
const outputUnit = ref("");
const ingredient_name = ref("");
const ingredient = ref(null);
const density = ref("");
const output = ref("");
const errors = ref({});
searchItemSelected(ingredient) {
this.ingredient = ingredient;
this.ingredient_name = ingredient.name;
this.density = ingredient.density;
},
const inputErrors = computed(() => getErrors("input"));
const outputUnitErrors = computed(() => getErrors("output_unit"));
const densityErrors = computed(() => getErrors("density"));
updateOutput: debounce(function() {
this.loadResource(api.getCalculate(this.input, this.outputUnit, this.density)
const updateOutput = debounce(function() {
if (input.value && input.value.length > 0) {
loadResource(api.getCalculate(input.value, outputUnit.value, ingredient.value ? ingredient.value.ingredient_id : null, density.value)
.then(data => {
this.output = data.output;
this.errors = data.errors;
output.value = data.output;
errors.value = data.errors;
})
);
}, 500)
},
);
}
}, 500);
watch: {
'ingredient_name': function(val) {
if (this.ingredient && this.ingredient.name !== val) {
this.ingredient = null;
}
}
},
watch(ingredient_name, function(val) {
if (ingredient.value && ingredient.value.name !== val) {
ingredient.value = null;
}
});
created() {
this.$watch(
function() {
return this.input + this.outputUnit + this.density;
},
function() {
this.updateOutput();
}
)
},
watch(
[input, outputUnit, density, ingredient],
() => updateOutput()
);
components: {
function updateSearchItems(text) {
return api.getSearchIngredients(text);
}
function searchItemSelected(ingredient) {
ingredient.value = ingredient || null;
ingredient_name.value = ingredient.name || null;
density.value = ingredient.density || null;
}
function getErrors(type) {
if (errors.value[type] && errors.value[type].length > 0) {
return errors.value[type].join(", ");
} else {
return null;
}
}

View File

@ -0,0 +1,41 @@
<template>
<div>
<div v-if="food === null">
Loading...
</div>
<div v-else>
<food-show :food="food"></food-show>
</div>
<router-link v-if="appConfig.isLoggedIn" class="button" :to="{name: 'edit_food', params: { id: foodId }}">Edit</router-link>
<router-link class="button" to="/foods">Back</router-link>
</div>
</template>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute } from "vue-router";
import FoodShow from "./FoodShow";
import api from "../lib/Api";
import { useLoadResource } from "../lib/useLoadResource";
import { useAppConfigStore } from "../stores/appConfig";
const { loadResource } = useLoadResource();
const appConfig = useAppConfigStore();
const route = useRoute();
const food = ref(null);
const foodId = computed(() => route.params.id);
onBeforeMount(() => {
loadResource(
api.getFood(foodId.value)
.then(data => { food.value = data; return data; })
);
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,70 @@
<template>
<div>
<food-edit :food="food" :validation-errors="validationErrors" action="Creating"></food-edit>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/food">Cancel</router-link>
</div>
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import FoodEdit from "./FoodEdit";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
const { loadResource } = useLoadResource();
const router = useRouter();
const validationErrors = ref({});
const food = reactive({
name: null,
notes: null,
ndbn: null,
density: null,
water: null,
ash: null,
protein: null,
kcal: null,
fiber: null,
sugar: null,
carbohydrates: null,
calcium: null,
iron: null,
magnesium: null,
phosphorus: null,
potassium: null,
sodium: null,
zinc: null,
copper: null,
manganese: null,
vit_c: null,
vit_b6: null,
vit_b12: null,
vit_a: null,
vit_e: null,
vit_d: null,
vit_k: null,
cholesterol: null,
lipids: null,
food_units: []
});
function save() {
validationErrors.value = {}
loadResource(
api.postFood(food)
.then(() => router.push('/foods'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,53 @@
<template>
<div>
<div v-if="food === null">
Loading...
</div>
<div v-else>
<food-edit :food="food" :validation-errors="validationErrors"></food-edit>
</div>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/foods">Cancel</router-link>
</div>
</template>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import FoodEdit from "./FoodEdit";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { useLoadResource } from "../lib/useLoadResource";
const { loadResource } = useLoadResource();
const router = useRouter();
const route = useRoute();
const food = ref(null);
const validationErrors = ref({});
const foodId = computed(() => route.params.id);
onBeforeMount(() => {
loadResource(
api.getFood(foodId.value)
.then(data => { food.value = data; return data; })
);
});
function save() {
validationErrors.value = {};
loadResource(
api.patchFood(food.value)
.then(() => router.push({name: 'food', params: {id: foodId.value }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,131 @@
<template>
<div>
<h1 class="title">Ingredients</h1>
<div class="buttons">
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
</div>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="food" @changePage="changePage"></app-pager>
<table class="table is-fullwidth is-narrow">
<thead>
<tr>
<th>Name</th>
<th>USDA</th>
<th>KCal per 100g</th>
<th>Density (oz/cup)</th>
<th></th>
</tr>
<tr>
<th>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search names" v-model="search.name">
</div>
</div>
</th>
<th colspan="4"></th>
</tr>
</thead>
<transition-group tag="tbody" name="fade" mode="out-in">
<tr v-for="i in foods" :key="i.id">
<td><router-link :to="{name: 'food', params: { id: i.id } }">{{i.name}}</router-link></td>
<td><app-icon v-if="i.usda" icon="check"></app-icon></td>
<td>{{i.kcal}}</td>
<td>{{i.density}}</td>
<td>
<template v-if="appConfig.isLoggedIn">
<router-link class="button" :to="{name: 'edit_food', params: { id: i.id } }">
<app-icon icon="pencil"></app-icon>
</router-link>
<button type="button" class="button is-danger" @click="deleteFood(i)">
<app-icon icon="x"></app-icon>
</button>
</template>
</td>
</tr>
</transition-group>
</table>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="food" @changePage="changePage"></app-pager>
<div class="buttons">
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_food'}" class="button is-primary">Create Ingredient</router-link>
</div>
<app-confirm :open="showConfirmFoodDelete" title="Delete Ingredient?" :message="confirmFoodDeleteMessage" @cancel="foodDeleteCancel" @confirm="foodDeleteConfirm"></app-confirm>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { useAppConfigStore } from "../stores/appConfig";
import { useLoadResource } from "../lib/useLoadResource";
const appConfig = useAppConfigStore();
const { loadResource } = useLoadResource();
const foodData = ref(null);
const foodForDeletion = ref(null);
const search = reactive({
page: 1,
per: 25,
name: null
});
const foods = computed(() => foodData.value?.foods || []);
const totalPages = computed(() => foodData.value?.total_pages || 0);
const currentPage = computed(() => foodData.value?.current_page || 0);
const showConfirmFoodDelete = computed(() => foodForDeletion.value !== null);
const confirmFoodDeleteMessage = computed(() => {
if (foodForDeletion.value !== null) {
return `Are you sure you want to delete ${foodForDeletion.value.name}?`;
} else {
return "??";
}
});
const getList = debounce(function() {
return loadResource(
api.getFoodList(search.page, search.per, search.name)
.then(data => foodData.value = data)
);
}, 500, {leading: true, trailing: true});
watch(search,
() => getList(),
{
deep: true,
immediate: true
}
);
function changePage(idx) {
search.page = idx;
}
function deleteFood(food) {
foodForDeletion.value = food;
}
function foodDeleteCancel() {
foodForDeletion.value = null;
}
function foodDeleteConfirm() {
if (foodForDeletion.value !== null) {
loadResource(
api.deleteFood(foodForDeletion.value.id).then(res => {
foodForDeletion.value = null;
return getList();
})
);
}
}
</script>

View File

@ -1,49 +0,0 @@
<template>
<div>
<div v-if="ingredient === null">
Loading...
</div>
<div v-else>
<ingredient-show :ingredient="ingredient"></ingredient-show>
</div>
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_ingredient', params: { id: ingredientId }}">Edit</router-link>
<router-link class="button" to="/ingredients">Back</router-link>
</div>
</template>
<script>
import IngredientShow from "./IngredientShow";
import { mapState } from "vuex";
import api from "../lib/Api";
export default {
data: function () {
return {
ingredient: null
}
},
computed: {
...mapState({
ingredientId: state => state.route.params.id,
})
},
created() {
this.loadResource(
api.getIngredient(this.ingredientId)
.then(data => { this.ingredient = data; return data; })
);
},
components: {
IngredientShow
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,77 +0,0 @@
<template>
<div>
<ingredient-edit :ingredient="ingredient" :validation-errors="validationErrors" action="Creating"></ingredient-edit>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/ingredients">Cancel</router-link>
</div>
</template>
<script>
import IngredientEdit from "./IngredientEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
export default {
data() {
return {
ingredient: {
name: null,
notes: null,
ndbn: null,
density: null,
water: null,
ash: null,
protein: null,
kcal: null,
fiber: null,
sugar: null,
carbohydrates: null,
calcium: null,
iron: null,
magnesium: null,
phosphorus: null,
potassium: null,
sodium: null,
zinc: null,
copper: null,
manganese: null,
vit_c: null,
vit_b6: null,
vit_b12: null,
vit_a: null,
vit_e: null,
vit_d: null,
vit_k: null,
cholesterol: null,
lipids: null,
ingredient_units: []
},
validationErrors: {}
}
},
methods: {
save() {
this.validationErrors = {}
this.loadResource(
api.postIngredient(this.ingredient)
.then(() => this.$router.push('/ingredients'))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
components: {
IngredientEdit
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,64 +0,0 @@
<template>
<div>
<div v-if="ingredient === null">
Loading...
</div>
<div v-else>
<ingredient-edit :ingredient="ingredient" :validation-errors="validationErrors"></ingredient-edit>
</div>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/ingredients">Cancel</router-link>
</div>
</template>
<script>
import IngredientEdit from "./IngredientEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
export default {
data: function () {
return {
ingredient: null,
validationErrors: {}
};
},
computed: {
...mapState({
ingredientId: state => state.route.params.id,
})
},
methods: {
save() {
this.validationErrors = {};
this.loadResource(
api.patchIngredient(this.ingredient)
.then(() => this.$router.push({name: 'ingredient', params: {id: this.ingredientId }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
created() {
this.loadResource(
api.getIngredient(this.ingredientId)
.then(data => { this.ingredient = data; return data; })
);
},
components: {
IngredientEdit
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,163 +0,0 @@
<template>
<div>
<h1 class="title">Ingredients</h1>
<div class="buttons">
<router-link v-if="isLoggedIn" :to="{name: 'new_ingredient'}" class="button is-primary">Create Ingredient</router-link>
</div>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="ingredient" @changePage="changePage"></app-pager>
<table class="table is-fullwidth is-narrow">
<thead>
<tr>
<th>Name</th>
<th>USDA</th>
<th>KCal per 100g</th>
<th>Density (oz/cup)</th>
<th></th>
</tr>
<tr>
<th>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search names" v-model="search.name">
</div>
</div>
</th>
<th colspan="4"></th>
</tr>
</thead>
<tbody>
<tr v-for="i in ingredients" :key="i.id">
<td><router-link :to="{name: 'ingredient', params: { id: i.id } }">{{i.name}}</router-link></td>
<td><app-icon v-if="i.usda" icon="check"></app-icon></td>
<td>{{i.kcal}}</td>
<td>{{i.density}}</td>
<td>
<router-link v-if="isLoggedIn" class="button" :to="{name: 'edit_ingredient', params: { id: i.id } }">
<app-icon icon="pencil"></app-icon>
</router-link>
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteIngredient(i)">
<app-icon icon="x"></app-icon>
</button>
</td>
</tr>
</tbody>
</table>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="ingredient" @changePage="changePage"></app-pager>
<div class="buttons">
<router-link v-if="isLoggedIn" :to="{name: 'new_ingredient'}" class="button is-primary">Create Ingredient</router-link>
</div>
<app-confirm :open="showConfirmIngredientDelete" :message="confirmIngredientDeleteMessage" :cancel="ingredientDeleteCancel" :confirm="ingredientDeleteConfirm"></app-confirm>
</div>
</template>
<script>
import api from "../lib/Api";
import debounce from "lodash/debounce";
export default {
data() {
return {
ingredientData: null,
ingredientForDeletion: null,
search: {
page: 1,
per: 25,
name: null
}
};
},
computed: {
ingredients() {
if (this.ingredientData) {
return this.ingredientData.ingredients;
} else {
return [];
}
},
totalPages() {
if (this.ingredientData) {
return this.ingredientData.total_pages
}
return 0;
},
currentPage() {
if (this.ingredientData) {
return this.ingredientData.current_page
}
return 0;
},
showConfirmIngredientDelete() {
return this.ingredientForDeletion !== null;
},
confirmIngredientDeleteMessage() {
if (this.ingredientForDeletion !== null) {
return `Are you sure you want to delete ${this.ingredientForDeletion.name}?`;
} else {
return "??";
}
}
},
methods: {
changePage(idx) {
this.search.page = idx;
},
getList: debounce(function() {
return this.loadResource(
api.getIngredientList(this.search.page, this.search.per, this.search.name)
.then(data => this.ingredientData = data)
);
}, 500, {leading: true, trailing: true}),
deleteIngredient(ingredient) {
this.ingredientForDeletion = ingredient;
},
ingredientDeleteCancel() {
this.ingredientForDeletion = null;
},
ingredientDeleteConfirm() {
if (this.ingredientForDeletion !== null) {
this.loadResource(
api.deleteIngredient(this.ingredientForDeletion.id).then(res => {
this.ingredientForDeletion = null;
return this.getList();
})
);
console.log("This is where the thing happens!!");
this.ingredientForDeletion = null;
}
}
},
created() {
this.$watch("search",
() => this.getList(),
{
deep: true,
immediate: true
}
);
},
components: {
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,229 +2,317 @@
<div>
<h1 class="title">Recipes</h1>
<router-link v-if="isLoggedIn" :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
<router-link v-if="appConfig.isLoggedIn" :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
<table class="table is-fullwidth">
<app-loading v-if="localLoading"></app-loading>
<table class="table is-fullwidth" :class="{ small: isTouch }">
<thead>
<tr>
<th v-for="h in tableHeader" :key="h.name">
<a v-if="h.sort" href="#" @click.prevent="setSort(h.name)">
{{h.label}}
<app-icon v-if="search.sortColumn === h.name" size="sm" :icon="search.sortDirection === 'asc' ? 'caret-bottom' : 'caret-top'"></app-icon>
</a>
<span v-else>{{h.label}}</span>
</th>
<th></th>
</tr>
<tr>
<td>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search names" v-model="search.name">
</div>
</div>
</td>
<td>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search tags" v-model="search.tags">
</div>
</div>
</td>
<td colspan="5"></td>
</tr>
<tr>
<th v-for="h in tableHeader" :key="h.name">
<a v-if="h.sort" href="#" @click.prevent="setSort(h.name)">
{{h.label}}
<app-icon v-if="search.column === h.name" size="sm" :icon="search.direction === 'asc' ? 'caret-bottom' : 'caret-top'"></app-icon>
</a>
<span v-else>{{h.label}}</span>
</th>
<th></th>
</tr>
<tr>
<td>
<app-search-text placeholder="search names" :value="search.name" @update:modelValue="setSearchName($event)"></app-search-text>
</td>
<td>
<app-search-text placeholder="search tags" :value="search.tags" @update:modelValue="setSearchTags($event)"></app-search-text>
</td>
<td colspan="5"></td>
</tr>
</thead>
<tbody>
<tr v-for="r in recipes" :key="r.id">
<td><router-link :to="{name: 'recipe', params: { id: r.id } }">{{r.name}}</router-link></td>
<td>
<div class="tags">
<span class="tag" v-for="tag in r.tags" :key="tag">{{tag}}</span>
<transition-group name="fade" tag="tbody">
<tr v-for="r in recipes" :key="r.id">
<td><router-link :to="{name: 'recipe', params: { id: r.id } }">{{r.name}}</router-link></td>
<td>
<div class="tags">
<span class="tag" v-for="tag in r.tags" :key="tag">{{tag}}</span>
</div>
</td>
<td>
<app-rating v-if="r.rating !== null" :model-value="r.rating" readonly></app-rating>
<span v-else>--</span>
</td>
<td>{{ r.yields }}</td>
<td class="recipe-time">{{ formatRecipeTime(r.total_time, r.active_time) }}</td>
<td><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td>
<td>
<app-dropdown hover v-if="appConfig.isLoggedIn" class="is-right">
<template #button>
<button class="button is-small">
<app-icon icon="menu"></app-icon>
</button>
</template>
<div class="dropdown-item">
<router-link :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary is-fullwidth">
<app-icon icon="star" size="md"></app-icon> <span>Add Log Entry</span>
</router-link>
</div>
</td>
<td>
<app-rating v-if="r.rating !== null" :value="r.rating" readonly></app-rating>
<span v-else>--</span>
</td>
<td>{{ r.yields }}</td>
<td 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>
<div class="field is-grouped">
<div class="control">
<router-link v-if="isLoggedIn" :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary">
<app-icon icon="star" size="md"></app-icon>
</router-link>
</div>
<div class="control">
<router-link v-if="isLoggedIn" :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary">
<app-icon icon="pencil" size="md"></app-icon>
</router-link>
</div>
<div class="control">
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteRecipe(r)">
<app-icon icon="x" size="md"></app-icon>
</button>
</div>
<div class="dropdown-item">
<router-link :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary is-fullwidth">
<app-icon icon="pencil" size="md"></app-icon> <span>Edit Recipe</span>
</router-link>
</div>
</td>
</tr>
</tbody>
<div class="dropdown-item">
<button type="button" class="button is-danger is-fullwidth" @click="deleteRecipe(r)">
<app-icon icon="x" size="md"></app-icon> <span>Delete Recipe</span>
</button>
</div>
</app-dropdown>
</td>
</tr>
</transition-group>
</table>
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
<div v-if="!localLoading && recipes.length === 0">
No Recipes
</div>
<app-confirm :open="showConfirmRecipeDelete" :message="confirmRecipeDeleteMessage" :cancel="recipeDeleteCancel" :confirm="recipeDeleteConfirm"></app-confirm>
<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>
</div>
</template>
<script>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { useRouter } from 'vue-router'
import api from "../lib/Api";
import debounce from "lodash/debounce";
import AppLoading from "./AppLoading";
import { useAppConfigStore } from "../stores/appConfig";
import { useMediaQueryStore } from "../stores/mediaQuery";
import { useLoadResource } from "../lib/useLoadResource";
export default {
data() {
return {
recipeData: null,
recipeForDeletion: null,
search: {
sortColumn: 'created_at',
sortDirection: 'desc',
page: 1,
per: 25,
name: null,
tags: null
}
};
},
const appConfig = useAppConfigStore();
const mediaQueries = useMediaQueryStore();
const { loadResource, localLoading } = useLoadResource();
const router = useRouter();
computed: {
recipes() {
if (this.recipeData) {
return this.recipeData.recipes;
} else {
return [];
}
},
const props = defineProps({
searchQuery: {
type: Object,
required: false,
default: {}
}
});
tableHeader() {
return [
{name: 'name', label: 'Name', sort: true},
{name: 'tags', label: 'Tags', sort: false},
{name: 'rating', label: 'Rating', sort: true},
{name: 'yields', label: 'Yields', sort: false},
{name: 'total_time', label: 'Time', sort: true},
{name: 'created_at', label: 'Created', sort: true}
]
},
const tableHeader = [
{name: 'name', label: 'Name', sort: true},
{name: 'tags', label: 'Tags', sort: false},
{name: 'rating', label: 'Rating', sort: true},
{name: 'yields', label: 'Yields', sort: false},
{name: 'total_time', label: 'Time', sort: true},
{name: 'created_at', label: 'Created', sort: true}
];
totalPages() {
if (this.recipeData) {
return this.recipeData.total_pages;
}
return 0;
},
const recipeData = ref(null);
const recipeForDeletion = ref(null);
const isTouch = computed(() => mediaQueries.touch);
currentPage() {
if (this.recipeData) {
return this.recipeData.current_page;
}
return 0;
},
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
}));
showConfirmRecipeDelete() {
return this.recipeForDeletion !== null;
},
const recipes = computed(() => {
if (recipeData.value) {
return recipeData.value.recipes;
} else {
return [];
}
});
confirmRecipeDeleteMessage() {
if (this.showConfirmRecipeDelete) {
return `Are you sure you want to delete ${this.recipeForDeletion.name}?`;
} else {
return "??";
}
}
},
const totalPages = computed(() => {
if (recipeData.value) {
return recipeData.value.total_pages;
}
return 0;
});
methods: {
changePage(idx) {
this.search.page = idx;
},
const currentPage = computed(() => {
if (recipeData.value) {
return recipeData.value.current_page;
}
return 0;
});
setSort(col) {
if (this.search.sortColumn === col) {
this.search.sortDirection = this.search.sortDirection === "desc" ? "asc" : "desc";
} else {
this.search.sortColumn = col;
this.search.sortDirection = "asc";
}
},
const showConfirmRecipeDelete = computed(() => recipeForDeletion.value !== null);
deleteRecipe(recipe) {
this.recipeForDeletion = recipe;
},
const confirmRecipeDeleteMessage = computed(() => {
if (showConfirmRecipeDelete.value) {
return `Are you sure you want to delete ${recipeForDeletion.value.name}?`;
} else {
return "??";
}
});
recipeDeleteConfirm() {
if (this.recipeForDeletion !== null) {
this.loadResource(
api.deleteRecipe(this.recipeForDeletion.id).then(() => {
this.recipeForDeletion = null;
return this.getList();
})
);
}
},
watch(search, () => {
getList().then(() => appConfig.initialLoad = true);
}, {
deep: true,
immediate: true
});
recipeDeleteCancel() {
this.recipeForDeletion = null;
},
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)
);
}
getList: debounce(function() {
return this.loadResource(
api.getRecipeList(this.search.page, this.search.per, this.search.sortColumn, this.search.sortDirection, this.search.name, this.search.tags, data => this.recipeData = data)
);
}, 500, {leading: true, trailing: true}),
formatRecipeTime(total, active) {
let str = "";
if (total && total > 0) {
str += total;
}
if (active && active > 0) {
if (str.length) {
str += " (" + active + ")";
} else {
str += active;
}
}
return str;
}
},
created() {
this.$watch("search",
() => this.getList(),
{
deep: true,
immediate: true
}
);
},
components: {
function buildQueryParams() {
return {
name: props.searchQuery.name,
tags: props.searchQuery.tags,
column: props.searchQuery.column,
direction: props.searchQuery.direction,
page: props.searchQuery.page,
per: props.searchQuery.per
}
}
function redirectToParams(params) {
const rParams = {};
if (params.name) {
rParams.name = params.name;
}
if (params.tags) {
rParams.tags = params.tags;
}
if (params.column) {
rParams.column = params.column;
}
if (params.direction) {
rParams.direction = params.direction;
}
if (params.page) {
rParams.page = params.page;
}
if (params.per) {
rParams.per = params.per;
}
router.push({name: 'recipeList', query: rParams});
}
function changePage(idx) {
const p = buildQueryParams();
p.page = idx;
redirectToParams(p);
}
function setSort(col) {
const p = buildQueryParams();
if (p.column === col) {
p.direction = p.direction === "desc" ? "asc" : "desc";
} else {
p.column = col;
p.direction = "asc";
}
redirectToParams(p);
}
function setSearchName(name) {
const p = buildQueryParams();
if (name !== p.name) {
p.name = name;
p.page = null;
redirectToParams(p);
}
}
function setSearchTags(tags) {
const p = buildQueryParams();
if (tags !== p.tags) {
p.tags = tags;
p.page = null;
redirectToParams(p);
}
}
function deleteRecipe(recipe) {
recipeForDeletion.value = recipe;
}
function recipeDeleteConfirm() {
if (recipeForDeletion.value !== null) {
loadResource(
api.deleteRecipe(recipeForDeletion.value.id).then(() => {
recipeForDeletion.value = null;
return getList();
})
);
}
}
function recipeDeleteCancel() {
recipeForDeletion.value = null;
}
function formatRecipeTime(total, active) {
let str = "";
if (total && total > 0) {
str += total;
}
if (active && active > 0) {
if (str.length) {
str += " (" + active + ")";
} else {
str += active;
}
}
return str;
}
</script>
<style lang="scss" scoped>
.recipe-time {
white-space: nowrap;
}
.table th {
white-space: nowrap;
}
.table.small {
td, th {
&:nth-of-type(3), &:nth-of-type(4), &:nth-of-type(5), &:nth-of-type(6) {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div>
<h1 class="title is-3">
Tasks
<app-dropdown button-class="is-primary" :open="showListDropdown" :label="listSelectLabel" @open="showListDropdown = true" @close="showListDropdown = false">
<task-list-dropdown-item v-for="l in taskLists" :key="l.id" :task-list="l" :active="currentTaskList !== null && currentTaskList.id === l.id" @select="selectList" @delete="deleteList"></task-list-dropdown-item>
<hr class="dropdown-divider" v-if="taskLists.length > 0">
<div class="dropdown-item">
<task-list-mini-form :task-list="newList" :validation-errors="newListValidationErrors" @save="saveNewList"></task-list-mini-form>
</div>
</app-dropdown>
</h1>
<div class="columns" v-if="currentTaskList !== null">
<div class="column is-6-widescreen is-8-desktop is-10-tablet">
<task-item-list :task-list="currentTaskList"></task-item-list>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeMount, ref } from "vue";
import * as Errors from '../lib/Errors';
import { useTaskStore } from "../stores/task";
import TaskListMiniForm from "./TaskListMiniForm";
import TaskListDropdownItem from "./TaskListDropdownItem";
import TaskItemList from "./TaskItemList";
import { useLoadResource } from "../lib/useLoadResource";
const newListTemplate = function() {
return {
name: ''
};
};
const { loadResource } = useLoadResource();
const taskStore = useTaskStore();
const showListDropdown = ref(false);
const newList = ref(newListTemplate());
const newListValidationErrors = ref({});
const taskLists = computed(() => taskStore.taskLists);
const currentTaskList = computed(() => taskStore.currentTaskList);
const listSelectLabel = computed(() => {
if (currentTaskList.value === null) {
return "Select or Create a List";
} else {
return currentTaskList.value.name;
}
});
onBeforeMount(() => {
loadResource(taskStore.refreshTaskLists());
});
function selectList(list) {
taskStore.setCurrentTaskList(list);
showListDropdown.value = false;
}
function saveNewList() {
loadResource(
taskStore.createTaskList(newList.value)
.then(() => showListDropdown.value = false)
.then(() => { newList.value = newListTemplate(); newListValidationErrors.value = {}; } )
.catch(Errors.onlyFor(Errors.ApiValidationError, err => newListValidationErrors.value = err.validationErrors()))
);
}
function deleteList(list) {
loadResource(taskStore.deleteTaskList(list));
}
function deleteAllItems() {
loadResource(
taskStore.deleteTaskItems({
taskList: currentTaskList.value,
taskItems: currentTaskList.value.task_items
})
);
}
</script>
<style lang="scss" scoped>
</style>

View File

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

View File

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

View File

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

View File

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

6
app/javascript/iconic/js/iconic.min.js vendored Executable file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,44 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="account" width="128" height="128" class="iconic iconic-account" viewBox="0 0 128 128">
<g><title>Sign In &amp; Out</title></g>
<g data-width="128" data-height="128" class="iconic-lg iconic-container" display="inline">
<g class="iconic-account-login">
<path stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-entrance iconic-property-accent iconic-property-stroke" d="M46 4h78v120h-78" fill="none"/>
<path stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-arrow iconic-account-login-arrow-line iconic-property-stroke" d="M4 64h76" fill="none"/>
<path d="M90.7 63.2l-21.4-12.4c-.7-.4-1.3-.1-1.3.7v25c0 .8.6 1.2 1.3.7l21.4-12.5c.7-.4.7-1 0-1.5z" class="iconic-account-login-arrow iconic-account-login-arrow-head iconic-property-fill"/>
</g>
<g class="iconic-account-logout" display="none">
<path stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-entrance iconic-property-accent iconic-property-stroke" d="M46 4h78v120h-78" fill="none"/>
<path stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-arrow iconic-account-logout-arrow-line iconic-property-stroke" d="M88 64h-76" fill="none"/>
<path d="M1.3 64.8l21.4 12.5c.7.4 1.3.1 1.3-.7v-25c0-.8-.6-1.2-1.3-.7l-21.4 12.3c-.7.5-.7 1.1 0 1.6z" class="iconic-account-logout-arrow iconic-account-logout-arrow-head iconic-property-fill"/>
</g>
</g>
<g data-width="32" data-height="31" class="iconic-md iconic-container" display="none" transform="scale(4)">
<g class="iconic-account-login">
<path stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-entrance iconic-property-accent iconic-property-stroke" d="M12.5 1.5h18v28h-18" fill="none"/>
<path stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-arrow iconic-account-login-arrow-line iconic-property-stroke" d="M1.5 15.5h14.5" fill="none"/>
<path d="M20.6 15.8l-5.2 3.9c-.2.2-.4.1-.4-.2v-8c0-.3.2-.4.4-.2l5.2 3.9c.2.2.2.4 0 .6z" class="iconic-account-login-arrow iconic-account-login-arrow-head iconic-property-fill"/>
</g>
<g class="iconic-account-logout" display="none">
<path stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-entrance iconic-property-accent iconic-property-stroke" d="M12.5 1.5h18v28h-18" fill="none"/>
<path stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-arrow iconic-account-logout-arrow-line iconic-property-stroke" d="M19.5 15.5h-14.5" fill="none"/>
<path d="M.4 15.2l5.2-3.9c.2-.2.4-.1.4.2v8c0 .3-.2.4-.4.2l-5.2-3.9c-.2-.2-.2-.4 0-.6z" class="iconic-account-logout-arrow iconic-account-logout-arrow-head iconic-property-fill"/>
</g>
</g>
<g data-width="16" data-height="16" class="iconic-sm iconic-container" display="none" transform="scale(8)">
<g class="iconic-account-login">
<path stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-entrance iconic-property-accent iconic-property-stroke" d="M7 1h8v14h-8" fill="none"/>
<path stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-login-arrow iconic-account-login-arrow-line iconic-property-stroke" d="M1 8h9" fill="none"/>
<path class="iconic-account-login-arrow iconic-account-login-arrow-head iconic-property-fill" d="M12 8l-4-4v8z"/>
</g>
<g class="iconic-account-logout" display="none">
<path stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-entrance iconic-property-accent iconic-property-stroke" d="M7 1h8v14h-8" fill="none"/>
<path stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" class="iconic-account-logout-arrow iconic-account-logout-arrow-line iconic-property-stroke" d="M11 8h-9" fill="none"/>
<path class="iconic-account-logout-arrow iconic-account-logout-arrow-head iconic-property-fill" d="M0 8l4 4v-8z"/>
</g>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["account"]=iconicApis["account"]||function(t){var i=function(i){t.setAttribute("data-state",i);var e,n=t.querySelectorAll(".iconic-account-login"),o=t.querySelectorAll(".iconic-account-logout");if("logout"===i){for(e=0;e<n.length;e++)n[e].setAttribute("display","none");for(e=0;e<o.length;e++)o[e].setAttribute("display","inline")}else{for(e=0;e<n.length;e++)n[e].setAttribute("display","inline");for(e=0;e<o.length;e++)o[e].setAttribute("display","none")}},e=function(){var e=t.getAttribute("data-state");e&&i(e)};return{update:e,setState:i}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,38 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="action" width="128" height="128" class="iconic iconic-action" viewBox="0 0 128 128">
<g><title>Action</title></g>
<g data-width="128" data-height="96" class="iconic-lg iconic-container" display="inline" transform="translate(0 16)">
<g class="iconic-action-undo">
<path d="M30 59c0-27.1 21.9-49 49-49s49 21.9 49 49c0-32.6-26.4-59-59-59s-59 26.4-59 59v11h20v-11z" class="iconic-action-undo-stem iconic-property-fill"/>
<path d="M19.2 94.7l-18.4-29.4c-.4-.7-.1-1.3.7-1.3h37c.8 0 1.1.6.7 1.3l-18.4 29.4c-.4.7-1.2.7-1.6 0z" class="iconic-action-undo-arrowhead iconic-property-fill"/>
</g>
<g class="iconic-action-redo" display="none">
<path d="M98 59c0-27.1-21.9-49-49-49s-49 21.9-49 49c0-32.6 26.4-59 59-59s59 26.4 59 59v11h-20v-11z" class="iconic-action-redo-stem iconic-property-fill"/>
<path d="M108.8 94.7l18.4-29.5c.4-.7.1-1.3-.7-1.3h-37c-.8 0-1.1.6-.7 1.3l18.4 29.5c.4.7 1.2.7 1.6 0z" class="iconic-action-redo-arrowhead iconic-property-fill"/>
</g>
</g>
<g data-width="32" data-height="24" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 4)">
<g class="iconic-action-undo">
<path d="M9 14.5c0-6.4 5.1-11.5 11.5-11.5 6 0 10.9 4.7 11.4 10.5-.6-7.5-6.8-13.5-14.4-13.5-8 0-14.5 6.5-14.5 14.5v2.5h6v-2.5z" class="iconic-action-undo-stem iconic-property-fill"/>
<path d="M6.3 23.6l5.4-7.2c.2-.2.1-.4-.2-.4h-11c-.3 0-.4.2-.2.4l5.4 7.2c.2.2.4.2.6 0z" class="iconic-action-undo-arrowhead iconic-property-fill"/>
</g>
<g class="iconic-action-redo" display="none">
<path d="M23 14.5c0-6.4-5.1-11.5-11.5-11.5-6 0-10.9 4.7-11.5 10.5.6-7.5 6.9-13.5 14.5-13.5 8 0 14.5 6.5 14.5 14.5v2.5h-6v-2.5z" class="iconic-action-redo-stem iconic-property-fill"/>
<path d="M25.7 23.6l-5.4-7.2c-.2-.2-.1-.4.2-.4h11c.3 0 .4.2.2.4l-5.4 7.2c-.2.2-.4.2-.6 0z" class="iconic-action-redo-arrowhead iconic-property-fill"/>
</g>
</g>
<g data-width="16" data-height="12" class="iconic-sm iconic-container" display="none" transform="scale(8) translate(0 2)">
<g class="iconic-action-undo">
<path d="M2 9v-2c0-3.9 3.1-7 7-7s7 3.1 7 7c0-2.8-2.2-5-5-5s-5 2.2-5 5v2h-4z" class="iconic-action-undo-stem iconic-property-fill"/>
<path class="iconic-action-undo-arrowhead iconic-property-fill" d="M0 8l4 4 4-4z"/>
</g>
<g class="iconic-action-redo" display="none">
<path d="M10 9v-2c0-2.8-2.2-5-5-5s-5 2.2-5 5c0-3.9 3.1-7 7-7s7 3.1 7 7v2h-4z" class="iconic-action-redo-stem iconic-property-fill"/>
<path class="iconic-action-redo-arrowhead iconic-property-fill" d="M8 8l4 4 4-4z"/>
</g>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["action"]=iconicApis["action"]||function(t){var i=function(i){t.setAttribute("data-state",i);var n,e,o=t.querySelectorAll(".iconic-action-undo"),c=t.querySelectorAll(".iconic-action-redo");n=e="none","redo"===i?e="inline":n="inline";for(var a=0;a<o.length;a++)o[a].setAttribute("display",n),c[a].setAttribute("display",e)},n=function(){var n=t.getAttribute("data-state");n&&i(n)};return{update:n,setState:i}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,68 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-icon="align" width="128" height="128" class="iconic iconic-align" viewBox="0 0 128 128" preserveAspectRatio="xMidYMid meet">
<g><title>Align</title></g>
<g data-width="128" data-height="86" class="iconic-lg iconic-container" display="inline" transform="translate(0 21)" stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" fill="none">
<g class="iconic-align-left">
<path class="iconic-align-left-line-4 iconic-align-left-line-short iconic-property-stroke" d="M4 82h82"/>
<path class="iconic-align-left-line-3 iconic-align-left-line-long iconic-property-stroke" d="M4 56h120"/>
<path class="iconic-align-left-line-2 iconic-align-left-line-short iconic-property-stroke" d="M4 30h82"/>
<path class="iconic-align-left-line-1 iconic-align-left-line-long iconic-property-stroke" d="M4 4h120"/>
</g>
<g class="iconic-align-center" display="none">
<path class="iconic-align-center-line-4 iconic-align-center-line-short iconic-property-stroke" d="M23 82h82"/>
<path class="iconic-align-center-line-3 iconic-align-center-line-long iconic-property-stroke" d="M4 56h120"/>
<path class="iconic-align-center-line-2 iconic-align-center-line-short iconic-property-stroke" d="M23 30h82"/>
<path class="iconic-align-center-line-1 iconic-align-center-line-long iconic-property-stroke" d="M4 4h120"/>
</g>
<g class="iconic-align-right" display="none">
<path class="iconic-align-right-line-4 iconic-align-right-line-short iconic-property-stroke" d="M42 82h82"/>
<path class="iconic-align-right-line-3 iconic-align-right-line-long iconic-property-stroke" d="M4 56h120"/>
<path class="iconic-align-right-line-2 iconic-align-right-line-short iconic-property-stroke" d="M42 30h82"/>
<path class="iconic-align-right-line-1 iconic-align-right-line-long iconic-property-stroke" d="M4 4h120"/>
</g>
</g>
<g data-width="32" data-height="27" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 2)" stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" fill="none">
<g class="iconic-align-left" display="inline">
<path class="iconic-align-left-line-4 iconic-align-left-line-short iconic-property-stroke" d="M1.5 25.5h21"/>
<path class="iconic-align-left-line-3 iconic-align-left-line-long iconic-property-stroke" d="M1.5 17.5h29"/>
<path class="iconic-align-left-line-2 iconic-align-left-line-short iconic-property-stroke" d="M1.5 9.5h21"/>
<path class="iconic-align-left-line-1 iconic-align-left-line-long iconic-property-stroke" d="M1.5 1.5h29"/>
</g>
<g class="iconic-align-center" display="none">
<path class="iconic-align-center-line-4 iconic-align-center-line-short iconic-property-stroke" d="M5.5 25.5h21"/>
<path class="iconic-align-center-line-3 iconic-align-center-line-long iconic-property-stroke" d="M1.5 17.5h29"/>
<path class="iconic-align-center-line-2 iconic-align-center-line-short iconic-property-stroke" d="M5.5 9.5h21"/>
<path class="iconic-align-center-line-1 iconic-align-center-line-long iconic-property-stroke" d="M1.5 1.5h29"/>
</g>
<g class="iconic-align-right" display="none">
<path class="iconic-align-right-line-4 iconic-align-right-line-short iconic-property-stroke" d="M9.5 25.5h21"/>
<path class="iconic-align-right-line-3 iconic-align-right-line-long iconic-property-stroke" d="M1.5 17.5h29"/>
<path class="iconic-align-right-line-2 iconic-align-right-line-short iconic-property-stroke" d="M9.5 9.5h21"/>
<path class="iconic-align-right-line-1 iconic-align-right-line-long iconic-property-stroke" d="M1.5 1.5h29"/>
</g>
</g>
<g data-width="16" data-height="14" class="iconic-sm iconic-container" display="none" transform="scale(8) translate(0 1)" stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" fill="none">
<g class="iconic-align-left" display="inline">
<path class="iconic-align-left-line-4 iconic-align-left-line-short iconic-property-stroke" d="M1 13h10"/>
<path class="iconic-align-left-line-3 iconic-align-left-line-long iconic-property-stroke" d="M1 9h14"/>
<path class="iconic-align-left-line-2 iconic-align-left-line-short iconic-property-stroke" d="M1 5h10"/>
<path class="iconic-align-left-line-1 iconic-align-left-line-long iconic-property-stroke" d="M1 1h14"/>
</g>
<g class="iconic-align-center" display="none">
<path class="iconic-align-center-line-4 iconic-align-center-line-short iconic-property-stroke" d="M3 13h10"/>
<path class="iconic-align-center-line-3 iconic-align-center-line-long iconic-property-stroke" d="M1 9h14"/>
<path class="iconic-align-center-line-2 iconic-align-center-line-short iconic-property-stroke" d="M3 5h10"/>
<path class="iconic-align-center-line-1 iconic-align-center-line-long iconic-property-stroke" d="M1 1h14"/>
</g>
<g class="iconic-align-right" display="none">
<path class="iconic-align-right-line-4 iconic-align-right-line-short iconic-property-stroke" d="M5 13h10"/>
<path class="iconic-align-right-line-3 iconic-align-right-line-long iconic-property-stroke" d="M1 9h14"/>
<path class="iconic-align-right-line-2 iconic-align-right-line-short iconic-property-stroke" d="M5 5h10"/>
<path class="iconic-align-right-line-1 iconic-align-right-line-long iconic-property-stroke" d="M1 1h14"/>
</g>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["align"]=iconicApis["align"]||function(t){var i=function(i){t.setAttribute("data-text-alignment",i);var n,e,c,o=t.querySelectorAll(".iconic-align-left"),a=t.querySelectorAll(".iconic-align-center"),r=t.querySelectorAll(".iconic-align-right");switch(n=e=c="none",i){case"center":n="inline";break;case"right":c="inline";break;default:e="inline"}for(var l=0;l<o.length;l++)o[l].setAttribute("display",e),a[l].setAttribute("display",n),r[l].setAttribute("display",c)},n=function(){var n=t.getAttribute("data-text-alignment");n&&i(n)};return{update:n,setTextAlignment:i}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" class="iconic iconic-aperture" width="128" height="128" viewBox="0 0 128 128">
<g class="iconic-metadata">
<title>Aperture</title>
</g>
<g class="iconic-aperture-lg iconic-container iconic-lg" data-width="128" data-height="128" display="inline">
<path d="M48.989 86l-35.43-61.367c-8.487 10.859-13.559 24.516-13.559 39.367 0 7.73 1.371 15.14 3.882 22h45.107z" class="iconic-aperture-blade iconic-aperture-blade-6 iconic-property-fill" />
<path d="M75.547 88h-70.877c8.17 20.176 26.322 35.227 48.337 39.04l22.54-39.04z" class="iconic-aperture-blade iconic-aperture-blade-5 iconic-property-fill" />
<path d="M90.558 66l-35.434 61.373c2.902.403 5.862.627 8.875.627 19.725 0 37.358-8.929 49.099-22.959l-22.54-39.041z" class="iconic-aperture-blade iconic-aperture-blade-4 iconic-property-fill" />
<path d="M79.011 42l35.43 61.367c8.487-10.859 13.559-24.516 13.559-39.367 0-7.73-1.371-15.14-3.882-22h-45.107z" class="iconic-aperture-blade iconic-aperture-blade-3 iconic-property-fill" />
<path d="M52.453 40h70.877c-8.17-20.176-26.322-35.227-48.337-39.04l-22.54 39.04z" class="iconic-aperture-blade iconic-aperture-blade-2 iconic-property-fill" />
<path d="M37.442 62l35.434-61.373c-2.902-.403-5.862-.627-8.875-.627-19.725 0-37.358 8.929-49.098 22.959l22.54 39.041z" class="iconic-aperture-blade iconic-aperture-blade-1 iconic-property-fill" />
</g>
<g class="iconic-aperture-md iconic-container iconic-md" data-width="32" data-height="32" display="none" transform="scale(4)">
<path d="M11.959 21l-8.563-14.832c-2.12 2.714-3.395 6.121-3.395 9.832 0 1.748.29 3.426.808 5h11.151z" class="iconic-aperture-blade iconic-aperture-blade-6 iconic-property-fill" />
<path d="M18.309 22h-17.136c1.978 4.883 6.289 8.569 11.556 9.663l5.579-9.663z" class="iconic-aperture-blade iconic-aperture-blade-5 iconic-property-fill" />
<path d="M22.351 17l-8.562 14.829c.725.1 1.459.17 2.211.17 4.743 0 8.99-2.075 11.92-5.354l-5.569-9.646z" class="iconic-aperture-blade iconic-aperture-blade-4 iconic-property-fill" />
<path d="M20.041 11l8.563 14.832c2.12-2.714 3.395-6.121 3.395-9.832 0-1.748-.29-3.426-.808-5h-11.151z" class="iconic-aperture-blade iconic-aperture-blade-3 iconic-property-fill" />
<path d="M13.691 10h17.135c-1.978-4.883-6.289-8.569-11.556-9.663l-5.579 9.663z" class="iconic-aperture-blade iconic-aperture-blade-2 iconic-property-fill" />
<path d="M9.649 15l8.562-14.829c-.725-.1-1.459-.17-2.211-.17-4.743 0-8.99 2.075-11.92 5.354l5.569 9.646z" class="iconic-aperture-blade iconic-aperture-blade-1 iconic-property-fill" />
</g>
<g class="iconic-aperture-sm iconic-container iconic-sm" data-width="16" data-height="16" display="none" transform="scale(8)">
<path d="M5.691 10l-3.993-6.916c-1.06 1.357-1.698 3.061-1.698 4.916 0 .692.097 1.36.262 2h5.428z" class="iconic-aperture-blade iconic-aperture-blade-6 iconic-property-fill" />
<path d="M8.577 11h-7.99c.925 2.283 2.872 4.037 5.276 4.702l2.715-4.702z" class="iconic-aperture-blade iconic-aperture-blade-5 iconic-property-fill" />
<path d="M10.887 9l-3.992 6.915c.362.05.729.085 1.105.085 2.183 0 4.16-.877 5.603-2.295l-2.717-4.705z" class="iconic-aperture-blade iconic-aperture-blade-4 iconic-property-fill" />
<path d="M14.302 12.916c1.06-1.357 1.698-3.061 1.698-4.916 0-.692-.097-1.36-.262-2h-5.428l3.993 6.916z" class="iconic-aperture-blade iconic-aperture-blade-3 iconic-property-fill" />
<path d="M7.423 5h7.99c-.925-2.283-2.872-4.037-5.275-4.702l-2.715 4.702z" class="iconic-aperture-blade iconic-aperture-blade-2 iconic-property-fill" />
<path d="M5.113 7l3.992-6.915c-.362-.05-.729-.085-1.105-.085-2.183 0-4.16.877-5.603 2.294l2.717 4.705z" class="iconic-aperture-blade iconic-aperture-blade-1 iconic-property-fill" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,29 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="arrow-right-angle-thick" width="128" height="128" class="iconic iconic-arrow-right-angle-thick" viewBox="0 0 128 128">
<g><title>Arrow 90 Degree Thick</title></g>
<g data-width="128" data-height="128" data-default-max-height="92" class="iconic-lg iconic-container" display="inline" transform="translate(0 16)">
<path stroke="#000" stroke-width="16" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-thick-stem iconic-property-stroke" d="M102 28h-94v58" fill="none"/>
<path d="M126.7 27.2l-33.4-20.4c-.7-.4-1.3-.1-1.3.7v41c0 .8.6 1.1 1.3.7l33.4-20.4c.7-.4.7-1.2 0-1.6z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill iconic-property-fill"/>
<path d="M126.6 27.4l-45.2-20.8c-.8-.3-1.4 0-1.4.9v41c0 .8.6 1.2 1.4.9l45.3-20.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-acute iconic-property-fill" display="none"/>
<path d="M126.6 27.4l-47.2-20.8c-.8-.3-1.1 0-.9.8l7 19.2c.3.8.3 2 0 2.8l-7 19.2c-.3.8.1 1.1.9.8l47.3-20.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-angled iconic-property-fill" display="none"/>
<path d="M127.6 28.4c.2-.2.2-.5 0-.7l-25.2-25.3c-.2-.2-.6-.4-.9-.4h-17c-.3 0-.3.2-.1.4l25.3 25.3c.2.2.2.5 0 .7l-25.3 25.2c-.2.2-.1.4.1.4h17c.3 0 .7-.2.9-.4l25.2-25.2z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-line iconic-property-fill" display="none"/>
</g>
<g data-width="32" data-height="32" data-default-max-height="26" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 2)">
<path stroke="#000" stroke-width="6" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-thick-stem iconic-property-stroke" d="M23 9h-20v14" fill="none"/>
<path d="M31.6 9.3l-8.2 6.4c-.2.2-.4.1-.4-.2v-13c0-.3.2-.4.4-.2l8.2 6.4c.2.2.2.4 0 .6z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill iconic-property-fill"/>
<path d="M31.6 9.3l-11.1 6.5c-.2.1-.4 0-.4-.2v-13c0-.3.2-.4.4-.2l11.1 6.5c.2 0 .2.2 0 .4z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-acute iconic-property-fill" display="none"/>
<path d="M31.6 9.2l-13.1 6.6c-.2.1-.4 0-.3-.2l2.6-6.1c.1-.3.1-.7 0-.9l-2.6-6.1c-.1-.3 0-.4.3-.2l13.1 6.6c.2 0 .2.2 0 .3z" class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-line iconic-property-fill" d="M32 9l-9-9h-6l9 9-9 9h6z" display="none"/>
</g>
<g data-width="16" data-height="16" data-default-max-height="14" class="iconic-sm iconic-container" display="none" transform="scale(8) translate(0 1)">
<path stroke="#000" stroke-width="4" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-thick-stem iconic-property-stroke" d="M10.5 5h-8.5v7" fill="none"/>
<path class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill iconic-property-fill" d="M16 5l-5 5v-10z"/>
<path class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-acute iconic-property-fill" d="M16 5l-8 5v-10z" display="none"/>
<path class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-fill-angled iconic-property-fill" d="M16 5l-9 5 2-5-2-5z" display="none"/>
<path class="iconic-arrow-right-angle-thick-head iconic-arrow-right-angle-thick-head-line iconic-property-fill" d="M16 5l-5-5h-4l5 5-5 5h4z" display="none"/>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["arrow-right-angle-thick"]=iconicApis["arrow-right-angle-thick"]||function(t){function e(e){t.setAttribute("data-head",e);for(var a=t.querySelectorAll(".iconic-arrow-right-angle-thick-head"),r=0;r<a.length;r++)a[r].setAttribute("display","none");var i=".iconic-arrow-right-angle-thick-head-"+e,n=t.querySelectorAll(i);n.length||(n=t.querySelectorAll(".iconic-arrow-right-angle-thick-head-fill"));for(var o=0;o<n.length;o++)n[o].setAttribute("display","inline")}function a(e){t.setAttribute("data-direction",e);var a,o,c;switch(e){case"bottom-left":a=["scale(-1 1)","rotate(0 64 64)","translate(-128 16)"],o=["scale(-4 4)","rotate(0 16 16)","translate(-32 2)"],c=["scale(-8 8)","rotate(0 8 8)","translate(-16 1)"];break;case"top-left":a=["rotate(180 64 64)","translate(0 16)"],o=["scale(4)","rotate(180 16 16)","translate(0 2)"],c=["scale(8)","rotate(180 8 8)","translate(0 1)"];break;case"top-right":a=["scale(-1 1)","rotate(180 64 64)","translate(128 16)"],o=["scale(-4 4)","rotate(180 16 16)","translate(32 2)"],c=["scale(-8 8)","rotate(180 8 8)","translate(16 1)"];break;case"right-top":a=["rotate(270 64 64)","translate(0 16)"],o=["scale(4)","rotate(270 16 16)","translate(0 2)"],c=["scale(8)","rotate(270 8 8)","translate(0 1)"];break;case"right-bottom":a=["scale(-1 1)","rotate(90 64 64)","translate(0 144)"],o=["scale(-4 4)","rotate(90 16 16)","translate(0 34)"],c=["scale(-8 8)","rotate(90 8 8)","translate(0 17)"];break;case"left-top":a=["scale(-1 1)","rotate(270 64 64)","translate(0 -112)"],o=["scale(-4 4)","rotate(270 16 16)","translate(0 -30)"],c=["scale(-8 8)","rotate(270 8 8)","translate(0 -15)"];break;case"left-bottom":a=["rotate(90 64 64)","translate(0 16)"],o=["scale(4)","rotate(90 16 16)","translate(0 3)"],c=["scale(8)","rotate(90 8 8)","translate(0 1)"];break;default:a=["translate(0 16)"],o=["scale(4)","translate(0 2)"],c=["scale(8)"]}r.setAttribute("transform",a.join(" ")),i.setAttribute("transform",o.join(" ")),n.setAttribute("transform",c.join(" "))}var r=t.querySelector(".iconic-lg"),i=t.querySelector(".iconic-md"),n=t.querySelector(".iconic-sm"),o=function(){var r=t.getAttribute("data-head")||"";e(r);var i=t.getAttribute("data-direction")||"";a(i)};return{update:o,setHead:e,setDirection:a}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,29 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="arrow-right-angle" width="128" height="128" class="iconic iconic-arrow-right-angle" viewBox="0 0 128 128">
<g><title>Arrow 90 Degree</title></g>
<g data-width="128" data-height="128" data-default-max-height="96" class="iconic-lg iconic-container" display="inline" transform="translate(0 18)">
<path stroke="#000" stroke-width="8" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-stem iconic-property-stroke" d="M114 24h-110v68" fill="none"/>
<path d="M126.7 23.2l-21.4-12.5c-.7-.4-1.3-.1-1.3.7v25c0 .8.6 1.2 1.3.7l21.4-12.5c.7-.3.7-.9 0-1.4z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill iconic-property-fill"/>
<path d="M126.6 23.4l-29.2-12.8c-.8-.3-1.4.1-1.4.9v25c0 .8.6 1.2 1.4.9l29.3-12.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-acute iconic-property-fill" display="none"/>
<path d="M126.6 23.5l-33.2-13c-.8-.3-1.1 0-.7.8l6.5 11.4c.4.7.4 1.9 0 2.6l-6.5 11.4c-.4.7-.1 1.1.7.8l33.2-12.9c.8-.4.8-.8 0-1.1z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-line iconic-property-fill" d="M128 24l-24-24h-10l24 24-24 24h10z" display="none"/>
</g>
<g data-width="32" data-height="32" data-default-max-height="26" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 3)">
<path stroke="#000" stroke-width="3" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-stem iconic-property-stroke" d="M27 6.5h-25.5v18" fill="none"/>
<path d="M31.6 6.8l-5.2 3.9c-.2.2-.4.1-.4-.2v-8c0-.3.2-.4.4-.2l5.2 3.9c.2.2.2.4 0 .6z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill iconic-property-fill"/>
<path d="M31.6 6.7l-8.1 4.1c-.2.1-.4 0-.4-.3v-8c0-.3.2-.4.4-.3l8.1 4.1c.2.1.2.3 0 .4z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-acute iconic-property-fill" display="none"/>
<path d="M31.5 6.7l-9.1 4.1c-.3.1-.4 0-.3-.3l1.7-3.5c.1-.3.1-.7 0-.9l-1.6-3.6c-.1-.3 0-.4.3-.3l9.1 4.1c.2.1.2.3-.1.4z" class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-line iconic-property-fill" d="M32 6.5l-7-6.5h-3.5l7 6.5-7 6.5h3.5z" display="none"/>
</g>
<g data-width="16" data-height="16" data-default-max-height="13" class="iconic-sm iconic-container" display="none" transform="scale(8) translate(0 1)">
<path stroke="#000" stroke-width="2" stroke-linecap="square" stroke-miterlimit="10" class="iconic-arrow-right-angle-stem iconic-property-stroke" d="M13 4h-12v8" fill="none"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill iconic-property-fill" d="M16 4l-3 3v-6z"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-acute iconic-property-fill" d="M16 4l-5 3v-6z" display="none"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-fill-angled iconic-property-fill" d="M16 4l-6 3 2-3-2-3z" display="none"/>
<path class="iconic-arrow-right-angle-head iconic-arrow-right-angle-head-line iconic-property-fill" d="M16 4l-4-4h-2l4 4-4 4h2z" display="none"/>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["arrow-right-angle"]=iconicApis["arrow-right-angle"]||function(t){function e(e){t.setAttribute("data-head",e);for(var a=t.querySelectorAll(".iconic-arrow-right-angle-head"),r=0;r<a.length;r++)a[r].setAttribute("display","none");var i=".iconic-arrow-right-angle-head-"+e,n=t.querySelectorAll(i);n.length||(n=t.querySelectorAll(".iconic-arrow-right-angle-head-fill"));for(var o=0;o<n.length;o++)n[o].setAttribute("display","inline")}function a(e){t.setAttribute("data-direction",e);var a,o,c;switch(e){case"bottom-left":a=["scale(-1 1)","rotate(0 64 64)","translate(-128 18)"],o=["scale(-4 4)","rotate(0 16 16)","translate(-32 3)"],c=["scale(-8 8)","rotate(0 8 8)","translate(-16 1)"];break;case"top-left":a=["rotate(180 64 64)","translate(0 18)"],o=["scale(4)","rotate(180 16 16)","translate(0 3)"],c=["scale(8)","rotate(180 8 8)","translate(0 1)"];break;case"top-right":a=["scale(-1 1)","rotate(180 64 64)","translate(128 18)"],o=["scale(-4 4)","rotate(180 16 16)","translate(32 3)"],c=["scale(-8 8)","rotate(180 8 8)","translate(16 1)"];break;case"right-top":a=["rotate(270 64 64)","translate(0 18)"],o=["scale(4)","rotate(270 16 16)","translate(0 3)"],c=["scale(8)","rotate(270 8 8)","translate(0 1)"];break;case"right-bottom":a=["scale(-1 1)","rotate(90 64 64)","translate(0 146)"],o=["scale(-4 4)","rotate(90 16 16)","translate(0 35)"],c=["scale(-8 8)","rotate(90 8 8)","translate(0 17)"];break;case"left-top":a=["scale(-1 1)","rotate(270 64 64)","translate(0 -110)"],o=["scale(-4 4)","rotate(270 16 16)","translate(0 -29)"],c=["scale(-8 8)","rotate(270 8 8)","translate(0 -15)"];break;case"left-bottom":a=["rotate(90 64 64)","translate(0 18)"],o=["scale(4)","rotate(90 16 16)","translate(0 3)"],c=["scale(8)","rotate(90 8 8)","translate(0 1)"];break;default:a=["translate(0 18)"],o=["scale(4)","translate(0 3)"],c=["scale(8)","translate(0 1)"]}r.setAttribute("transform",a.join(" ")),i.setAttribute("transform",o.join(" ")),n.setAttribute("transform",c.join(" "))}var r=t.querySelector(".iconic-lg"),i=t.querySelector(".iconic-md"),n=t.querySelector(".iconic-sm"),o=function(){var r=t.getAttribute("data-head")||"";r&&e(r);var i=t.getAttribute("data-direction")||"";i&&a(i)};return{update:o,setHead:e,setDirection:a}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,59 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="arrow-thick" width="128" height="128" class="iconic iconic-arrow-thick" viewBox="0 0 128 128">
<g><title>Arrow Thick</title></g>
<g data-width="128" data-height="128" data-default-max-height="76" class="iconic-lg iconic-container" display="inline" transform="translate(0 26)">
<path stroke="#000" stroke-width="16" stroke-miterlimit="10" class="iconic-arrow-thick-stem iconic-property-stroke" d="M0 38h110" fill="none"/>
<path d="M126.7 37.2l-33.4-20.4c-.7-.4-1.3-.1-1.3.7v41c0 .8.6 1.1 1.3.7l33.4-20.4c.7-.4.7-1.2 0-1.6z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill iconic-property-fill"/>
<path d="M126.6 37.4l-45.2-20.8c-.8-.3-1.4 0-1.4.9v41c0 .8.6 1.2 1.4.9l45.3-20.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-acute iconic-property-fill" display="none"/>
<path d="M127 36.8l-18-21.6c-.5-.6-1-.5-1 .3v45c0 .8.4 1 1 .3l18-21.6c.6-.7.6-1.7 0-2.4z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-oblique iconic-property-fill" display="none"/>
<path d="M126.6 37.4l-47.2-20.8c-.8-.3-1.1 0-.9.8l7 19.2c.3.8.3 2 0 2.8l-7 19.2c-.3.8.1 1.1.9.8l47.3-20.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-angled iconic-property-fill" display="none"/>
<path d="M126.7 37.2l-43.4-26.4c-.7-.4-1.3-.1-1.3.7v53c0 .8.6 1.2 1.3.7l43.4-26.4c.7-.5.7-1.1 0-1.6z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large iconic-property-fill" display="none"/>
<path d="M126.6 37.4l-57.2-26.8c-.8-.4-1.4 0-1.4.9v53c0 .8.6 1.2 1.4.9l57.3-26.7c.7-.4.7-1-.1-1.3z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-acute iconic-property-fill" display="none"/>
<path d="M126.6 37.4l-61.2-26.8c-.8-.3-1.2 0-1 .8l7.2 25.1c.2.8.2 2.1 0 2.9l-7.2 25.1c-.2.8.2 1.2 1 .8l61.3-26.8c.7-.2.7-.8-.1-1.1z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-oblique iconic-property-fill" d="M128 38l-28-34v68z" display="none"/>
<path d="M127.6 38.4c.2-.2.2-.5 0-.7l-25.3-25.3c-.2-.2-.6-.4-.9-.4h-17c-.3 0-.3.2-.1.4l25.3 25.3c.2.2.2.5 0 .7l-25.2 25.2c-.2.2-.1.4.1.4h17c.3 0 .7-.2.9-.4l25.2-25.2z" class="iconic-arrow-thick-head iconic-arrow-thick-head-line iconic-property-fill" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-acute iconic-property-fill" d="M128 38l-34-26h-20l34 26-34 26h20z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-oblique iconic-property-fill" d="M126 38l-18-28h-16l18 28-18 28h16z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large iconic-property-fill" d="M128 38l-34-34h-18l34 34-34 34h18z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-acute iconic-property-fill" d="M128 38l-46-34h-20l46 34-46 34h20z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-oblique iconic-property-fill" d="M126 38l-24-38h-16l24 38-24 38h16z" display="none"/>
</g>
<g data-width="32" data-height="32" data-default-max-height="24" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 4)">
<path stroke="#000" stroke-width="6" stroke-miterlimit="10" class="iconic-arrow-thick-stem iconic-property-stroke" d="M0 12h26" fill="none"/>
<path d="M31.6 12.3l-8.2 6.4c-.2.2-.4.1-.4-.2v-13c0-.3.2-.4.4-.2l8.2 6.4c.2.2.2.4 0 .6z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill iconic-property-fill"/>
<path d="M31.6 12.3l-11.1 6.5c-.2.1-.4 0-.4-.2v-13c0-.3.2-.4.4-.2l11.1 6.5c.2 0 .2.2 0 .4z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-acute iconic-property-fill" display="none"/>
<path d="M31.7 12.4l-5.4 7.2c-.2.2-.3.2-.3-.1v-15c0-.3.1-.3.3-.1l5.4 7.2c.2.2.2.6 0 .8z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-oblique iconic-property-fill" display="none"/>
<path d="M31.6 12.2l-13.1 6.6c-.2.1-.4 0-.3-.2l2.6-6.1c.1-.3.1-.7 0-.9l-2.6-6.1c-.1-.3 0-.4.3-.2l13.1 6.6c.2 0 .2.2 0 .3z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-angled iconic-property-fill" display="none"/>
<path d="M31.6 12.3l-12.2 9.4c-.2.2-.4.1-.4-.2v-19c0-.3.2-.4.4-.2l12.2 9.4c.2.2.2.4 0 .6z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large iconic-property-fill" display="none"/>
<path d="M31.6 12.3l-16.1 9.5c-.2.1-.4 0-.4-.2v-19c0-.3.2-.4.4-.2l16.1 9.5c.2 0 .2.2 0 .4z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-acute iconic-property-fill" display="none"/>
<path d="M31.7 12.4l-7.4 10.2c-.2.2-.3.2-.3-.1v-21c0-.3.1-.3.3-.1l7.4 10.2c.2.2.2.6 0 .8z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-oblique iconic-property-fill" display="none"/>
<path d="M31.6 12.2l-19.1 9.6c-.2.1-.4 0-.3-.2l3.6-9.1c.1-.3.1-.7 0-.9l-3.6-9.1c-.1-.3 0-.4.3-.2l19.1 9.6c.2 0 .2.2 0 .3z" class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line iconic-property-fill" d="M32 12l-9-9h-6l9 9-9 9h6z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-acute iconic-property-fill" d="M32 12l-12-9h-7l12 9-12 9h7z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-oblique iconic-property-fill" d="M32 12l-6-10h-6l6 10-6 10h6z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-acute iconic-property-fill" d="M32 12l-15-11h-8l15 11-15 11h8z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-oblique iconic-property-fill" d="M32 12l-7-12h-6l7 12-7 12h6z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large iconic-property-fill" d="M32 12l-11-11h-8l11 11-11 11h8z" display="none"/>
</g>
<g data-width="16" data-height="16" data-default-max-height="16" class="iconic-sm iconic-container" display="none" transform="scale(8)">
<path stroke="#000" stroke-width="4" stroke-miterlimit="10" class="iconic-arrow-thick-stem iconic-property-stroke" d="M0 8h12.25" fill="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill iconic-property-fill" d="M16 8l-5 5v-10z"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-acute iconic-property-fill" d="M16 8l-8 5v-10z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-oblique iconic-property-fill" d="M16 8l-4 6v-12z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-angled iconic-property-fill" d="M16 8l-9 5 2-5-2-5z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large iconic-property-fill" d="M16 8l-7 7v-14z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-acute iconic-property-fill" d="M16 8l-11 7v-14z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-oblique iconic-property-fill" d="M16 8l-5 8v-16z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-fill-large-angled iconic-property-fill" d="M16 8l-12 7 3-7-3-7z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line iconic-property-fill" d="M16 8l-6-6h-4l6 6-6 6h4z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-acute iconic-property-fill" d="M16 8l-8-6h-5l8 6-8 6h5z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-oblique iconic-property-fill" d="M16 8l-4-7h-4l4 7-4 7h4z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large iconic-property-fill" d="M16 8l-7-7h-4.5l7 7-7 7h4.5z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-acute iconic-property-fill" d="M16 8l-8-6h-5l8 6-8 6h5z" display="none"/>
<path class="iconic-arrow-thick-head iconic-arrow-thick-head-line-large-oblique iconic-property-fill" d="M16 8l-5-8h-4l5 8-5 8h4z" display="none"/>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["arrow-thick"]=iconicApis["arrow-thick"]||function(t){function e(e){t.setAttribute("data-head",e);for(var a=t.querySelectorAll(".iconic-arrow-thick-head"),r=0;r<a.length;r++)a[r].setAttribute("display","none");var i=".iconic-arrow-thick-head-"+e,o=t.querySelectorAll(i);o.length||(o=t.querySelectorAll(".iconic-arrow-thick-head-fill"));for(var n=0;n<o.length;n++)o[n].setAttribute("display","inline")}function a(e){t.setAttribute("data-direction",e);var a=0;switch(e){case"top":a=270;break;case"left":a=180;break;case"bottom":a=90;break;case"top-right":a=-45;break;case"top-left":a=-135;break;case"bottom-right":a=45;break;case"bottom-left":a=135;break;default:a=0}r.setAttribute("transform",["rotate("+a+" 64 64)","translate(0 26)"].join(" ")),i.setAttribute("transform",["scale(4)","rotate("+a+" 16 16)","translate(0 4)"].join(" ")),o.setAttribute("transform",["scale(8)","rotate("+a+" 8 8)"].join(" "))}var r=t.querySelector(".iconic-lg"),i=t.querySelector(".iconic-md"),o=t.querySelector(".iconic-sm"),n=function(){var r=t.getAttribute("data-head")||"";e(r);var i=t.getAttribute("data-direction")||"";a(i)};return{update:n,setHead:e,setDirection:a}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,59 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" data-icon="arrow" width="128" height="128" class="iconic iconic-arrow" viewBox="0 0 128 128">
<g><title>Arrow</title></g>
<g data-width="128" data-height="128" data-default-max-height="64" class="iconic-lg iconic-container" display="inline" transform="translate(0 32)">
<path stroke="#000" stroke-width="8" stroke-miterlimit="10" class="iconic-arrow-stem iconic-property-stroke" d="M0 32h118" fill="none"/>
<path d="M126.7 31.2l-21.4-12.5c-.7-.4-1.3-.1-1.3.7v25c0 .8.6 1.2 1.3.7l21.4-12.5c.7-.3.7-.9 0-1.4z" class="iconic-arrow-head iconic-arrow-head-fill iconic-property-fill"/>
<path d="M126.6 31.4l-29.2-12.8c-.8-.3-1.4.1-1.4.9v25c0 .8.6 1.2 1.4.9l29.3-12.8c.7-.3.7-.9-.1-1.2z" class="iconic-arrow-head iconic-arrow-head-fill-acute iconic-property-fill" display="none"/>
<path d="M126.6 31.5l-33.2-13c-.8-.3-1.1 0-.7.8l6.5 11.4c.4.7.4 1.9 0 2.6l-6.5 11.4c-.4.7-.1 1.1.7.8l33.2-12.9c.8-.4.8-.8 0-1.1z" class="iconic-arrow-head iconic-arrow-head-fill-angled iconic-property-fill" display="none"/>
<path d="M127.1 30.8l-12.2-15.6c-.5-.7-.9-.5-.9.3v33c0 .8.4 1 .9.3l12.2-15.6c.5-.7.5-1.7 0-2.4z" class="iconic-arrow-head iconic-arrow-head-fill-oblique iconic-property-fill" display="none"/>
<path d="M126.7 31.2l-31.4-18.4c-.7-.4-1.3-.1-1.3.7v37c0 .8.6 1.2 1.3.7l31.4-18.5c.7-.4.7-1 0-1.5z" class="iconic-arrow-head iconic-arrow-head-fill-large iconic-property-fill" display="none"/>
<path d="M126.6 31.4l-43.2-18.8c-.8-.3-1.4.1-1.4.9v37c0 .8.6 1.2 1.4.9l43.2-18.8c.8-.3.8-.9 0-1.2z" class="iconic-arrow-head iconic-arrow-head-fill-large-acute iconic-property-fill" display="none"/>
<path d="M127.1 30.8l-16.2-21.6c-.5-.7-.9-.5-.9.3v45c0 .8.4 1 .9.3l16.2-21.6c.5-.7.5-1.7 0-2.4z" class="iconic-arrow-head iconic-arrow-head-fill-large-oblique iconic-property-fill" display="none"/>
<path d="M126.6 31.4l-47.2-18.8c-.8-.3-1 0-.6.7l10.5 17.4c.4.7.4 1.9 0 2.6l-10.5 17.4c-.4.7-.1 1 .6.7l47.2-18.9c.8-.3.8-.7 0-1.1z" class="iconic-arrow-head iconic-arrow-head-fill-large-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line iconic-property-fill" d="M128 32l-24-24h-10l24 24-24 24h10z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-acute iconic-property-fill" d="M128 32l-32-24h-11l32 24-32 24h11z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-oblique iconic-property-fill" d="M128 32l-20-26h-10l20 26-20 26h10z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large iconic-property-fill" d="M128 32l-30-30h-11l30 30-30 30h11z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-acute iconic-property-fill" d="M128 32l-40-30h-12l40 30-40 30h12z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-oblique iconic-property-fill" d="M128 32l-24-32h-10l24 32-24 32h10z" display="none"/>
</g>
<g data-width="32" data-height="32" data-default-max-height="19" class="iconic-md iconic-container" display="none" transform="scale(4) translate(0 6) ">
<path stroke="#000" stroke-width="3" stroke-miterlimit="10" class="iconic-arrow-stem iconic-property-stroke" d="M0 9.5h29" fill="none"/>
<path d="M31.6 9.8l-5.2 3.9c-.2.2-.4.1-.4-.2v-8c0-.3.2-.4.4-.2l5.2 3.9c.2.2.2.4 0 .6z" class="iconic-arrow-head iconic-arrow-head-fill iconic-property-fill"/>
<path d="M31.6 9.7l-8.1 4.1c-.2.1-.4 0-.4-.3v-8c0-.3.2-.4.4-.3l8.1 4.1c.2.1.2.3 0 .4z" class="iconic-arrow-head iconic-arrow-head-fill-acute iconic-property-fill" display="none"/>
<path d="M31.7 9.9l-4.3 4.8c-.2.2-.3.1-.3-.1v-10c0-.3.2-.3.3-.1l4.3 4.8c.1 0 .1.4 0 .6z" class="iconic-arrow-head iconic-arrow-head-fill-oblique iconic-property-fill" display="none"/>
<path d="M31.5 9.7l-9.1 4.1c-.3.1-.4 0-.3-.3l1.6-3.6c.1-.3.1-.7 0-.9l-1.6-3.6c-.1-.3 0-.4.3-.3l9.1 4.1c.3.2.3.4 0 .5z" class="iconic-arrow-head iconic-arrow-head-fill-angled iconic-property-fill" display="none"/>
<path d="M31.6 9.8l-8.2 5.9c-.2.2-.4.1-.4-.2v-12c0-.3.2-.4.4-.2l8.2 5.9c.2.2.2.4 0 .6z" class="iconic-arrow-head iconic-arrow-head-fill-large iconic-property-fill" display="none"/>
<path d="M31.6 9.7l-11.1 6c-.2.1-.4 0-.4-.3v-12c0-.3.2-.4.4-.3l11.1 6c.2.3.2.5 0 .6z" class="iconic-arrow-head iconic-arrow-head-fill-large-acute iconic-property-fill" display="none"/>
<path d="M31.7 9.9l-6.3 6.8c-.2.2-.3.1-.3-.1v-14c0-.3.2-.3.3-.1l6.3 6.8c.1 0 .1.4 0 .6z" class="iconic-arrow-head iconic-arrow-head-fill-large-oblique iconic-property-fill" display="none"/>
<path d="M31.5 9.7l-13.1 6.1c-.3.1-.4 0-.2-.2l2.6-5.6c.1-.3.1-.7 0-.9l-2.6-5.6c-.1-.3 0-.4.2-.2l13.1 6.1c.3 0 .3.2 0 .3z" class="iconic-arrow-head iconic-arrow-head-fill-large-angled iconic-property-fill" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line iconic-property-fill" d="M32 9.5l-7-6.5h-3.5l7 6.5-7 6.5h3.5z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-acute iconic-property-fill" d="M32 9.5l-9-6.5h-4l9 6.5-9 6.5h4z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-oblique iconic-property-fill" d="M32 9.5l-6-7.5h-3l6 7.5-6 7.5h3z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large iconic-property-fill" d="M32 9.5l-11-8.5h-4l11 8.5-11 8.5h4z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-acute iconic-property-fill" d="M32 9.5l-11-8.5h-4l11 8.5-11 8.5h4z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-oblique iconic-property-fill" d="M32 9.5l-7-9.5h-3.5l7 9.5-7 9.5h3.5z" display="none"/>
</g>
<g data-width="16" data-height="16" data-default-max-height="10" class="iconic-sm iconic-container" display="none" transform="scale(8) translate(0 3)">
<path stroke="#000" stroke-width="2" stroke-miterlimit="10" class="iconic-arrow-stem iconic-property-stroke" d="M14 5h-14" fill="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill iconic-property-fill" d="M16 5l-3 3v-6z"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-acute iconic-property-fill" d="M16 5l-5 3v-6z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-oblique iconic-property-fill" d="M16 5l-3 4v-8z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-angled iconic-property-fill" d="M16 5l-6 3 2-3-2-3z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-large iconic-property-fill" d="M16 5l-4 4v-8z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-large-acute iconic-property-fill" d="M16 5l-7 4v-8z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-large-angled iconic-property-fill" d="M16 5l-8 4 2-4-2-4z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-fill-large-oblique iconic-property-fill" d="M16 5l-4 5v-10z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line iconic-property-fill" d="M16 5l-4-4h-2l4 4-4 4h2z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-acute iconic-property-fill" d="M16 5l-5-4h-2.5l5 4-5 4h2.5z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-oblique iconic-property-fill" d="M16 5l-3-4h-2l3 4-3 4h2z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large iconic-property-fill" d="M16 5l-5-5h-2.5l5 5-5 5h2.5z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-acute iconic-property-fill" d="M16 5l-6-5h-2.5l6 5-6 5h2.5z" display="none"/>
<path class="iconic-arrow-head iconic-arrow-head-line-large-oblique iconic-property-fill" d="M16 5l-4-5h-2l4 5-4 5h2z" display="none"/>
</g>
<script class="iconic-api" type="application/ecmascript">
//<![CDATA[
var iconicApis=window._Iconic?window._Iconic.smartIconApis:{};iconicApis["arrow"]=iconicApis["arrow"]||function(t){function e(e){t.setAttribute("data-head",e);for(var i=t.querySelectorAll(".iconic-arrow-head"),n=0;n<i.length;n++)i[n].setAttribute("display","none");var r=".iconic-arrow-head-"+e,a=t.querySelectorAll(r);a.length||(a=t.querySelectorAll(".iconic-arrow-head-fill"));for(var c=0;c<a.length;c++)a[c].setAttribute("display","inline")}function i(e){t.setAttribute("data-direction",e);var i=0;switch(e){case"top":i=270;break;case"left":i=180;break;case"bottom":i=90;break;case"top-right":i=-45;break;case"top-left":i=-135;break;case"bottom-right":i=45;break;case"bottom-left":i=135;break;default:i=0}n.setAttribute("transform",["rotate("+i+" 64 64)","translate(0 32)"].join(" ")),r.setAttribute("transform",["scale(4)","rotate("+i+" 16 16)","translate(0 8)"].join(" ")),a.setAttribute("transform",["scale(8)","rotate("+i+" 8 8)","translate(0 4)"].join(" "))}var n=t.querySelector(".iconic-lg"),r=t.querySelector(".iconic-md"),a=t.querySelector(".iconic-sm"),c=function(){var n=t.getAttribute("data-head")||"";e(n);var r=t.getAttribute("data-direction")||"";i(r)};return{update:c,setHead:e,setDirection:i}};
//]]>
</script>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" class="iconic iconic-audio-spectrum" width="128" height="128" viewBox="0 0 128 128">
<g class="iconic-metadata">
<title>Audio Spectrum</title>
</g>
<g class="iconic-audio-spectrum-lg iconic-container iconic-lg" data-width="120" data-height="92" display="inline" transform="translate(4 18)">
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-1 iconic-property-stroke" d="M4 42v8" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-2 iconic-property-stroke" d="M18 28v36" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-3 iconic-property-stroke" d="M32 20v52" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-mid iconic-audio-spectrum-frequency-4 iconic-property-stroke" d="M46 22v48" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-mid iconic-audio-spectrum-frequency-5 iconic-property-stroke" d="M60 4v84" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-mid iconic-audio-spectrum-frequency-6 iconic-property-stroke" d="M74 14v64" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-7 iconic-property-stroke" d="M88 26v40" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-8 iconic-property-stroke" d="M102 40v12" fill="none" />
<path stroke="#000" stroke-width="8" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-9 iconic-property-stroke" d="M116 44v4" fill="none" />
</g>
<g class="iconic-audio-spectrum-md iconic-container iconic-md" data-width="27" data-height="23" display="none" transform="scale(4) translate(2 4)">
<path stroke="#000" stroke-width="3" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-1 iconic-property-stroke" d="M1.5 10.5v2" fill="none" />
<path stroke="#000" stroke-width="3" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-2 iconic-property-stroke" d="M7.5 6.5v10" fill="none" />
<path stroke="#000" stroke-width="3" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-mid iconic-audio-spectrum-frequency-3 iconic-property-stroke" d="M13.5 1.5v20" fill="none" />
<path stroke="#000" stroke-width="3" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-4 iconic-property-stroke" d="M19.5 4.5v14" fill="none" />
<path stroke="#000" stroke-width="3" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-5 iconic-property-stroke" d="M25.5 9.5v4" fill="none" />
</g>
<g class="iconic-audio-spectrum-sm iconic-container iconic-sm" data-width="14" data-height="16" display="none" transform="scale(8) translate(1)">
<path stroke="#000" stroke-width="2" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-1 iconic-property-stroke" d="M1 7v2" fill="none" />
<path stroke="#000" stroke-width="2" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-low iconic-audio-spectrum-frequency-2 iconic-property-stroke" d="M5 3v10" fill="none" />
<path stroke="#000" stroke-width="2" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-3 iconic-property-stroke" d="M9 1v14" fill="none" />
<path stroke="#000" stroke-width="2" stroke-linecap="square" class="iconic-audio-spectrum-frequency iconic-audio-spectrum-frequency-high iconic-audio-spectrum-frequency-4 iconic-property-stroke" d="M13 6.031v3.969" fill="none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

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