138 Commits

Author SHA1 Message Date
859e5756ae add new language note to contributing guide
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-13 19:09:01 +00:00
45b46b761d update version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 04:20:07 +00:00
bda051ebc8 fix cannery logo on home page
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 04:12:37 +00:00
01fa306429 eliminate possible style conflicts
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-05 04:05:10 +00:00
5cff5d8280 update deps again
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 03:04:45 +00:00
71778d12a6 shorten config 2025-04-05 02:54:34 +00:00
9b721a170b fix migrations
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-05 02:46:59 +00:00
366a6d160d format migration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 01:20:35 +00:00
926d2fe6c2 fix cast_datetime
Some checks are pending
continuous-integration/drone/push Build is running
2025-04-05 01:19:20 +00:00
c7bd7238c6 improve accuracy of timestamps
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 01:13:00 +00:00
e2c17b6b51 fix drone
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 00:33:09 +00:00
20988ac1ec fix dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 00:31:35 +00:00
37d101a71e update deps
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2025-04-05 00:13:01 +00:00
449a92e4b7 remove npm engine requirement
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-15 02:30:28 +00:00
5d17ee0a11 fix broken install step
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-13 22:02:18 +00:00
b6b6cecc0a update deps
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-02-01 02:12:41 -05:00
08916a504f add button to resend email verification email 2025-02-01 02:12:41 -05:00
3eda522903 move staging to container 2025-02-01 02:12:41 -05:00
2e6e26006d fix style issues 2025-02-01 02:12:41 -05:00
b66d0ea8a1 add date range to range page 2025-02-01 02:12:41 -05:00
839e1d7124 fix dates not displaying properly 2025-01-31 22:33:07 -05:00
76834845a3 update deps 2025-01-12 16:51:27 -05:00
cc1413ade5 finish updating deps
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-10-26 17:16:04 -04:00
668e4c611b update gettext schema and use macros for cannery app
Some checks failed
continuous-integration/drone/push Build is failing
2024-10-26 17:07:32 -04:00
ab3d3721d6 allow filtering ammo types when creating new packs and fix some form errors not displaying on create 2024-10-26 17:07:32 -04:00
7e14f292a6 add slimselect to select elements with user content 2024-10-26 16:32:47 -04:00
16a5cb9254 fix registration page not offering all translations 2024-10-26 14:20:23 -04:00
f722f9901b update deps 2024-10-26 13:12:34 -04:00
6adae82e94 build armv7
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-16 17:50:43 -04:00
a87bf15f72 build arm
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-16 17:01:35 -04:00
75c0f8642b add project website to readme
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-15 09:44:34 -04:00
ec782515ac improve testing db timeout
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-28 13:49:26 -04:00
e1cb46cb97 fix dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-07-28 13:35:58 -04:00
56a49ed2e3 fix changesets
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2024-07-28 13:17:33 -04:00
f25c151956 add debounces to more fields 2024-07-28 13:05:50 -04:00
c2ddc2ae0d update deps 2024-07-28 12:21:36 -04:00
33e4d26a3d fix emails 2024-07-28 12:21:36 -04:00
179d67a896 update versions 2024-07-28 12:21:35 -04:00
15354d6004 update versions
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-18 09:14:53 -04:00
e358cd6e4e downgrade versions until hex is supported in build
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-06-16 12:13:34 -04:00
202b70dc66 fix changeset warning
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2024-06-16 11:48:49 -04:00
b963baa49d fix an issue with emails not being able to be sent 2024-06-16 11:46:35 -04:00
70701a27d3 fix issue with oban exception logger 2024-06-16 11:42:00 -04:00
67dc16d222 update deps 2024-06-16 11:42:00 -04:00
fa35038426 update to elixir 1.17 2024-06-16 11:42:00 -04:00
d896257602 actually fix bar graph
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-19 00:28:30 -04:00
4ca51a3f53 update dependencies
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2024-03-19 00:01:27 -04:00
96b05e8332 Make bar graph ignore gaps 2024-03-19 00:00:51 -04:00
557a2cac3d Improve login page autocomplete behavior
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-18 23:39:06 -04:00
e16e04c114 combine imports
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-18 23:26:41 -04:00
bbe4d82303 Use bar graph instead of line graph 2024-03-18 23:26:32 -04:00
c69d7843ab fix layout issues
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-23 23:34:04 -05:00
c18f59050c fix missing ssl and crypto packages
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-02-23 21:57:25 -05:00
67d688fc1e create italian gettext
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2024-02-23 21:22:51 -05:00
28e5fa56c3 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
e301d3dd17 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
4881cf6edb Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
6b61c45889 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
4a674a0504 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
7e6959fb3b Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
22f13b0c57 make ammo packs in containers directly navigable in table view 2024-02-23 21:20:32 -05:00
31024dcc0d fix test mode warning 2024-02-23 21:16:46 -05:00
e843014502 fix credo check for is_admin? 2024-02-23 21:13:34 -05:00
5d146ce6af fix credo check for is_already_admin? 2024-02-23 21:13:23 -05:00
27cda3733e update elixir deps 2024-02-23 21:12:11 -05:00
1965ecba32 bump version 2024-02-23 21:04:25 -05:00
69e40c6d18 update elixir deps 2024-02-23 21:04:25 -05:00
34b4b24e67 update npm deps 2024-02-23 21:04:25 -05:00
7ebed8d4c0 update tool versions 2024-02-23 21:04:25 -05:00
b5619b8606 run mix format
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-07 19:15:07 -04:00
ef28de53a1 update hex dependencies 2023-09-07 19:11:57 -04:00
fcd5dbc605 bump version
Some checks failed
continuous-integration/drone/push Build is failing
2023-09-07 19:07:56 -04:00
7738e68292 run npm audit fix --force 2023-09-07 19:06:56 -04:00
df645a6188 update node packages 2023-09-07 19:06:21 -04:00
bed4fbaf54 update dependencies 2023-09-07 19:05:35 -04:00
f94ef0a2ca fix range page title
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-05 23:36:25 -04:00
7cdb8af690 update dependencies 2023-06-05 23:32:52 -04:00
52c4ab45d5 fix class filter helper functions
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-05 23:17:43 -04:00
a35f43d6df rename Ammo.get_average_cost and Ammo.get_historical_count 2023-06-05 23:17:43 -04:00
9edeb1e803 improve Ammo.get_grouped_round_count 2023-06-05 23:17:43 -04:00
7e55446b3e improve ActivityLog.list_shot_records 2023-06-05 23:17:43 -04:00
9643e9f46d improve Ammo.get_round_count 2023-06-05 23:17:39 -04:00
8466fcd1f9 improve ActivityLog.get_grouped_used_counts 2023-06-05 23:16:47 -04:00
e713a2e108 improve ActivityLog.get_used_count 2023-06-05 23:16:00 -04:00
a8fa321040 use sr for shot record in sql 2023-06-05 23:16:00 -04:00
f0536f3030 improve Ammo.get_grouped_packs_count 2023-06-05 23:15:57 -04:00
a94d2eebf4 improve Ammo.get_packs_count 2023-06-05 23:06:28 -04:00
cfc56519f5 fix user registration controller 2023-06-04 00:07:31 -04:00
e80c2018be improve Ammo.list_packs 2023-06-04 00:00:51 -04:00
71fdd42d96 improve Ammo.list_types 2023-06-03 20:14:20 -04:00
8e99a57994 improve Containers.list_containers 2023-06-03 20:12:06 -04:00
7c42dd8a3a improve Containers.list_tags 2023-06-03 19:54:51 -04:00
79c97d7502 fix error/404 pages not rendering properly 2023-05-12 22:59:53 -04:00
2e488fa26c fix ammo type sql naming issues 2023-05-12 22:22:46 -04:00
2179bd5d86 fix table component ids 2023-05-12 21:55:59 -04:00
49628cb9bb pattern match on user struct in more cases 2023-05-12 21:48:19 -04:00
8a58d53dc1 fix pack sql naming issues 2023-05-12 21:48:04 -04:00
9306d0f970 disable arm builds
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 21:35:37 -04:00
763c86a379 build in arm64 and amd64
Some checks are pending
continuous-integration/drone/push Build is running
2023-04-16 17:05:05 -04:00
b85b1735c0 remove maintain attrs
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 01:10:45 -04:00
ab1a288928 change invite path
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 00:46:49 -04:00
e6ef0a8c68 improve tests
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 21:47:50 -04:00
beeaf521c5 update npm dependencies
Some checks reported errors
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build was killed
2023-04-14 23:56:22 -04:00
8cb6068b85 make tests async
Some checks failed
continuous-integration/drone/push Build is failing
2023-04-14 23:48:50 -04:00
334d841d57 add pack lot number to search
Some checks failed
continuous-integration/drone/push Build is failing
2023-04-14 23:38:28 -04:00
1037f37be2 upgrade to phoenix 1.7 2023-04-14 23:34:11 -04:00
1796fb822f fix logger errors 2023-04-14 18:25:06 -04:00
8ed64f9c87 update elixir dependencies 2023-04-14 18:20:53 -04:00
dd4a9f7119 upgrade npm dependencies 2023-04-14 18:17:11 -04:00
dbafaad500 update node and npm 2023-04-14 18:11:44 -04:00
eb4ce07b5f update erlang 2023-04-14 18:10:59 -04:00
2b7550a954 update elixir 2023-04-14 18:09:39 -04:00
abaccac9f0 use :rifle as default ammo type
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-10 20:13:56 -04:00
2b81978adb make bullet type rifle/pistol type-only field 2023-04-10 20:13:55 -04:00
17bfe1a987 remove duplicate chamber size column 2023-04-10 20:13:38 -04:00
81f21a02c4 export shotgun fields
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-31 22:34:48 -04:00
9a17d4ad24 add lot number to packs 2023-03-30 23:36:19 -04:00
9835fe3f5e bump version
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-30 22:32:53 -04:00
4dee8808f3 improve migrations 2023-03-30 22:24:29 -04:00
65c70ca398 fix name collisions 2023-03-30 22:23:54 -04:00
550f6a6420 add migration for ammo type table and column 2023-03-30 22:02:44 -04:00
88c3f15fc8 rename ammo type files to type 2023-03-30 22:02:36 -04:00
c33f15603b rename ammo type to type 2023-03-30 22:02:36 -04:00
98775359af rename shot groups to shot records in database 2023-03-30 21:39:08 -04:00
e0e7b25bc4 add more text replacements 2023-03-30 21:38:56 -04:00
bdddf65685 remove unnecessary index churn 2023-03-30 21:38:09 -04:00
6f50702b11 rename shot group files to shot record 2023-03-30 20:44:41 -04:00
5f8d1a917f shot groups to shot records 2023-03-30 20:43:30 -04:00
32801828fa fix shot records table disappearing after selecting an empty ammo class
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-30 20:08:37 -04:00
6ed3312ea8 add db migrations for ammo group to pack and ammo type class 2023-03-30 20:08:26 -04:00
b122253b9b improve tests 2023-03-30 20:08:20 -04:00
a68a16bf06 fix ammo type table not displaying class 2023-03-30 20:08:14 -04:00
4b6d0952f8 rename ammo groups to packs everywhere 2023-03-30 20:08:11 -04:00
0544b58ab6 rename ammo group files to pack 2023-03-30 20:07:28 -04:00
6d26103784 rename ammo groups to packs 2023-03-30 20:07:16 -04:00
0cae7c2940 rename ammo_type type to class
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-28 23:08:40 -04:00
1e645b5bb8 generate fonts with correct filename
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-28 22:03:14 -04:00
bab2b26c13 use atom keys in tests
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-28 21:57:29 -04:00
268 changed files with 17009 additions and 34130 deletions

View File

@ -17,7 +17,7 @@ steps:
- .mix - .mix
- name: test - name: test
image: elixir:1.14.1-alpine image: elixir:1.18.3-otp-27-alpine
environment: environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld HOST: testing.example.tld
@ -26,23 +26,25 @@ steps:
MIX_ENV: test MIX_ENV: test
commands: commands:
- apk add --no-cache build-base npm git - apk add --no-cache build-base npm git
- mix local.rebar --force --if-missing - mix local.rebar --force
- mix local.hex --force --if-missing - mix local.hex --force
- mix deps.get - mix deps.get
- npm set cache .npm - npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline - npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy - mix do phx.digest, gettext.extract, assets.deploy
- mix do phx.digest, gettext.extract
- mix test.all - mix test.all
- name: build and publish stable - name: build and publish stable
image: thegeeklab/drone-docker-buildx image: plugins/docker
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
purge: true purge: true
compress: true compress: true
platforms: linux/amd64,linux/arm/v7 platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
@ -53,13 +55,16 @@ steps:
- stable - stable
- name: build and publish tagged version - name: build and publish tagged version
image: thegeeklab/drone-docker-buildx image: plugins/docker
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
purge: true purge: true
compress: true compress: true
platforms: linux/amd64,linux/arm/v7 platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:

View File

@ -1,6 +1,6 @@
[ [
import_deps: [:ecto, :phoenix], import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
subdirectories: ["priv/*/migrations"], subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter] plugins: [Phoenix.LiveView.HTMLFormatter]
] ]

9
.gitignore vendored
View File

@ -28,10 +28,11 @@ npm-debug.log
# The directory NPM downloads your dependencies sources to. # The directory NPM downloads your dependencies sources to.
/assets/node_modules/ /assets/node_modules/
# Since we are building assets from assets/, # Ignore assets that are produced by build tools.
# we ignore priv/static. You may want to comment /priv/static/assets/
# this depending on your deployment strategy.
/priv/static/ # Ignore digested assets cache.
/priv/static/cache_manifest.json
# vs code # vs code
.elixir_ls/ .elixir_ls/

View File

@ -1,3 +1,3 @@
elixir 1.14.1-otp-25 elixir 1.18.3-otp-27
erlang 25.1.2 erlang 27.3.1
nodejs 18.9.1 nodejs 23.10.0

View File

@ -1,3 +1,79 @@
# v0.9.14
- Update deps
- Fix wrapping issues with search bars
- Improve accuracy of timestamps
# v0.9.13
- Add button to resend email verification email
- Move staging to container, rather than ammo
- Add date restriction dropdown to range page
- Fix dates not rendering properly in table
- Update deps
# v0.9.12
- Allow filtering ammo types when creating new packs
- Add SlimSelect to select elements with user content
- Fix registration page not offering all translations
- Update deps
# v0.9.11
- Fix an issue with emails not being able to be sent for real this time
- Fix some dropdowns not filling in the correct data
- Add debounces to more fields
- Update deps
# v0.9.10
- Fix issue with logger failing on oban exceptions
- Fix an issue with emails not being able to be sent
- Update deps
# v0.9.9
- Actually fix bar graph
# v0.9.8
- Make bar graph ignore empty days
- Update dependencies
# v0.9.7
- Fix margin on bottom of page
- Use bar graph instead of line graph
- Improve login page autocomplete behavior
# v0.9.6
- Make ammo packs in containers directly navigable in table view
- Update dependencies
# v0.9.5
- Update dependencies
# v0.9.4
- Code quality fixes
- Fix error/404 pages not rendering properly
- Update dependencies
- Fix Range page title
# v0.9.3
- Update dependencies
- Add pack lot number to search
- Improve tests
- Change invite path slightly
- Disable arm builds since ci fails to build
# v0.9.2
- Add lot number to packs
- Don't show price paid and lot number columns when displaying packs if not used
- Fix additional shotgun fields not being exportable
- Fixes duplicate chamber size column for ammo types
- Hide bullet type field when editing/creating shotgun ammo types
- Fix ammo type creation not displaying all the necessary fields on first load
# v0.9.1
- Rename ammo type's "type" to "class" to avoid confusion
- Rename "ammo type" to "type" to avoid confusion
- Fixes type search
- Fixes shot records table disappearing after selecting an empty ammo class
- Code quality improvements
# v0.9.0 # v0.9.0
- Add length limits to all string fields - Add length limits to all string fields
- Add selectable ammo types - Add selectable ammo types
@ -51,7 +127,7 @@
# v0.8.0 # v0.8.0
- Add search to catalog, ammo, container, tag and range index pages - Add search to catalog, ammo, container, tag and range index pages
- Tweak urls for catalog, ammo, containers, tags and shot records - Tweak urls for catalog, ammo, containers, tags and shot records
- Fix bug with shot group chart not drawing lines between days correctly - Fix bug with shot record chart not drawing lines between days correctly
- Improve cards across app (make them line up with each other) - Improve cards across app (make them line up with each other)
- Update translations and add spanish!!! (thank you Brea and Hannah!) - Update translations and add spanish!!! (thank you Brea and Hannah!)
@ -63,7 +139,7 @@
- Fix toggle button styling - Fix toggle button styling
- Miscellanous code improvements - Miscellanous code improvements
- Improve container index table - Improve container index table
- Fix bug with ammo not updating after deleting shot group - Fix bug with ammo not updating after deleting shot record
- Replace ammo "added on" with "purchased on" - Replace ammo "added on" with "purchased on"
- Miscellaneous wording improvements - Miscellaneous wording improvements
- Update translations - Update translations
@ -72,8 +148,8 @@
- Add shading to table component - Add shading to table component
- Fix chart to sum by day - Fix chart to sum by day
- Fix whitespace when copying invite url - Fix whitespace when copying invite url
- Make ammo type show page also display ammo groups as table - Make ammo type show page also display packs as table
- Make container show page also display ammo groups as table - Make container show page also display packs as table
- Display CPR for ammo packs - Display CPR for ammo packs
- Add original count for ammo packs - Add original count for ammo packs
- Add ammo pack CPR and original count to json export - Add ammo pack CPR and original count to json export
@ -97,7 +173,7 @@
- Add ammo type cloning - Add ammo type cloning
- Add container cloning - Add container cloning
- Fix bug with moving ammo packs between containers - Fix bug with moving ammo packs between containers
- Add button to set rounds left to 0 when creating a shot group - Add button to set rounds left to 0 when creating a shot record
- Update project dependencies - Update project dependencies
# v0.5.4 # v0.5.4
@ -149,8 +225,8 @@
# v0.3.0 # v0.3.0
- Fix ammo type counts not showing when count is 0 - Fix ammo type counts not showing when count is 0
- Add prompt to create first container before first ammo group - Add prompt to create first container before first ammo group
- Edit and delete shot groups from ammo group show page - Edit and delete shot records from ammo group show page
- Use today's date when adding new shot groups - Use today's date when adding new shot records
- Create multiple ammo groups at one time - Create multiple ammo groups at one time
# v0.2.3 # v0.2.3

View File

@ -10,6 +10,9 @@ status](https://weblate.bubbletea.dev/widgets/cannery/-/287x66-black.png)](https
If you're multilingual, this project can use your translations! Visit If you're multilingual, this project can use your translations! Visit
[weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information. [weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information.
Also, if your language isn't displayed here, I'd love to add that language so
you can start! Please contact me at
(shibao@bubbletea.dev)[mailto:shibao@bubbletea.dev] and let me know!
## Style Tips ## Style Tips
@ -17,8 +20,8 @@ If you're multilingual, this project can use your translations! Visit
functions as short as possible while keeping variable names descriptive! For functions as short as possible while keeping variable names descriptive! For
instance, use inline `do:` blocks for short functions and make your aliases as instance, use inline `do:` blocks for short functions and make your aliases as
short as possible without introducing ambiguity. short as possible without introducing ambiguity.
- I.e. since there's only one `AmmoGroup` in the app, please alias - I.e. since there's only one `Pack` in the app, please alias
`AmmoGroup.t()` instead of using `Cannery.Ammo.AmmoGroup.t()` `Pack.t()` instead of using `Cannery.Ammo.Pack.t()`
- Use pipelines when possible. If only calling a single method, a pipeline isn't - Use pipelines when possible. If only calling a single method, a pipeline isn't
strictly necessary but still encouraged for future modification. strictly necessary but still encouraged for future modification.
- Please add typespecs to your functions! Even your private functions may be - Please add typespecs to your functions! Even your private functions may be
@ -127,7 +130,7 @@ In `test` mode (or in the Docker container), Cannery will listen for the same en
In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime: In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime:
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
- `SMTP_HOST`: The url for your SMTP email provider. Must be set - `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! - `SMTP_USERNAME`: The username for your SMTP relay. Must be set!

View File

@ -1,4 +1,4 @@
FROM elixir:1.14.1-alpine AS build FROM elixir:1.18.3-otp-27-alpine AS build
# install build dependencies # install build dependencies
RUN apk add --no-cache build-base npm git python3 RUN apk add --no-cache build-base npm git python3
@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.hex --force && \ RUN mix local.rebar --force && \
mix local.rebar --force mix local.hex --force
# set build ENV # set build ENV
ENV MIX_ENV=prod ENV MIX_ENV=prod
@ -25,24 +25,25 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
COPY lib lib COPY lib lib
COPY priv priv COPY priv priv
COPY assets assets COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix do phx.digest, gettext.extract RUN mix do phx.digest, gettext.extract
# compile and build release # compile and build release
# uncomment COPY if rel/ exists # uncomment COPY if rel/ exists
# COPY rel rel # COPY rel rel
RUN mix do compile, release RUN mix do assets.deploy, compile, release
# prepare release image # prepare release image
FROM alpine:latest AS app FROM alpine:latest AS app
RUN apk upgrade --no-cache && \ RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs
WORKDIR /app WORKDIR /app
RUN chown nobody:nobody /app RUN chown nobody:nobody /app
ENV MIX_ENV=prod
USER nobody:nobody USER nobody:nobody
COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./ COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./

View File

@ -13,8 +13,8 @@ The self-hosted firearm tracker website.
# Features # Features
- Create containers to store your ammunition, and tag them with custom tags - Create containers to store your ammunition, and tag them with custom tags
- Add ammunition types to Cannery, and then ammunition groups to your containers - Add ammunition types to Cannery, and then ammo packs to your containers
- Stage groups of ammo for range day and record your ammo usage - Stage ammo packs for range day and track your usage with shot records
- Invitations via invite tokens or public registration - Invitations via invite tokens or public registration
# Installation # Installation
@ -60,7 +60,7 @@ You can use the following environment variables to configure Cannery in
Defaults to `false`. Defaults to `false`.
- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`.
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to - `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`. public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US` - `LOCALE`: Sets a custom default locale. Defaults to `en_US`
@ -94,6 +94,7 @@ license can be found at
# Links # Links
- [Website](https://cannery.app): Project website
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please - [Github](https://github.com/shibaobun/cannery): Source code mirror, please

View File

@ -1,5 +0,0 @@
{
"presets": [
"@babel/preset-env"
]
}

View File

@ -1,154 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
$fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "components";
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
// disconnect toast
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}

View File

@ -1,57 +0,0 @@
@layer components {
.input {
@apply rounded-lg px-4 py-2 border focus:outline-none;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
@apply text-black;
}
.checkbox {
-ms-transform: scale(1.5);
-moz-transform: scale(1.5);
-webkit-transform: scale(1.5);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply leading-5 tracking-wide;
}
.btn {
@apply focus:outline-none px-4 py-2 rounded-lg;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600;
@apply border-primary-500 focus:border-primary-600 active:border-primary-600;
@apply text-white;
}
.btn-alert {
@apply bg-red-600 focus:bg-red-700 active:bg-red-800;
@apply border-red-600 focus:border-red-700 active:border-red-800;
@apply text-white;
}
.hr {
@apply border border-primary-300 w-full max-w-2xl;
}
.hr-light {
@apply border border-white w-full max-w-2xl;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
}

266
assets/css/style.css Normal file
View File

@ -0,0 +1,266 @@
@import "tailwindcss" source("../..");
@theme {
--color-primary-50: oklch(0.985 0.002 247.839);
--color-primary-100: oklch(0.967 0.003 264.542);
--color-primary-200: oklch(0.928 0.006 264.531);
--color-primary-300: oklch(0.872 0.01 258.338);
--color-primary-400: oklch(0.707 0.022 261.325);
--color-primary-500: oklch(0.551 0.027 264.364);
--color-primary-600: oklch(0.446 0.03 256.802);
--color-primary-700: oklch(0.373 0.034 259.733);
--color-primary-800: oklch(0.278 0.033 256.848);
--color-primary-900: oklch(0.21 0.034 264.665);
--color-primary-950: oklch(0.13 0.028 261.692);
}
@import "@fortawesome/fontawesome-free/css/fontawesome" source("../..");
@import "@fortawesome/fontawesome-free/css/regular" source("../..");
@import "@fortawesome/fontawesome-free/css/solid" source("../..");
@import "@fortawesome/fontawesome-free/css/brands" source("../..");
@import "slim-select/styles" source("../..");
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
/* disconnect toast */
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}
/* components */
.input, .ss-main, .ss-content, .ss-search input[type="search"] {
@apply px-4 py-2 rounded-lg border focus:outline-hidden;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
@apply text-black;
}
.checkbox {
-ms-transform: scale(1.5);
-moz-transform: scale(1.5);
-webkit-transform: scale(1.5);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply tracking-wide leading-5;
}
.btn {
@apply px-4 py-2 rounded-lg focus:outline-hidden;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600;
@apply border-primary-500 focus:border-primary-600 active:border-primary-600;
@apply text-white;
}
.btn-alert {
@apply bg-rose-600 focus:bg-rose-700 active:bg-rose-800;
@apply border-rose-600 focus:border-rose-700 active:border-rose-800;
@apply text-white;
}
.hr {
@apply w-full max-w-2xl border border-primary-300;
}
.hr-light {
@apply w-full max-w-2xl border border-white;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
/* slim select */
.ss-main.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.ss-open-above {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.ss-content.ss-open-below {
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.ss-content.input-primary .ss-search input[type="search"] {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.ss-open-above .ss-search {
padding: var(--ss-spacing-l) 0 0 0;
}
.ss-content.ss-open-below .ss-search {
padding: 0 0 var(--ss-spacing-l) 0;
}
.ss-content.ss-open-above .ss-list > *:not(:first-child) {
margin: var(--ss-spacing-l) 0 0 0;
}
.ss-content.ss-open-below .ss-list > *:not(:last-child) {
margin: 0 0 var(--ss-spacing-l) 0;
}
.ss-content .ss-list .ss-option {
border-radius: var(--ss-border-radius);
}

View File

@ -1,7 +1,3 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import '../css/app.scss'
// If you want to use Phoenix channels, run `mix help phx.gen.channel` // If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below. // to get started and then uncomment the line below.
// import "./user_socket.js" // import "./user_socket.js"
@ -24,16 +20,16 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration. // Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart'
import Date from './date' import Date from './date'
import DateTime from './datetime' import DateTime from './datetime'
import ShotLogChart from './shot_log_chart'
import SlimSelect from './slim_select'
import topbar from 'topbar'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, { const liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, MaintainAttrs, ShotLogChart } hooks: { Date, DateTime, ShotLogChart, SlimSelect }
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -1,7 +1,7 @@
export default { export default {
displayDate (el) { displayDate (el) {
const date = const date =
Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }) Intl.DateTimeFormat([], { timeZone: 'UTC', dateStyle: 'short' })
.format(new Date(el.dateTime)) .format(new Date(el.dateTime))
el.innerText = date el.innerText = date

View File

@ -1,7 +1,7 @@
export default { export default {
displayDateTime (el) { displayDateTime (el) {
const date = const date =
Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' }) Intl.DateTimeFormat([], { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'long' })
.format(new Date(el.dateTime)) .format(new Date(el.dateTime))
el.innerText = date el.innerText = date

View File

@ -1,11 +0,0 @@
// maintain user adjusted attributes, like textbox length on phoenix liveview
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
export default {
attrs () {
const attrs = this.el.getAttribute('data-attrs')
if (attrs) { return attrs.split(', ') } else { return [] }
},
beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) },
updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) }
}

View File

@ -1,13 +1,12 @@
import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js' import Chart from 'chart.js/auto'
import 'chartjs-adapter-date-fns' import 'chartjs-adapter-date-fns'
Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale)
export default { export default {
initalizeChart (el) { initalizeChart (el) {
const data = JSON.parse(el.dataset.chartData) const data = JSON.parse(el.dataset.chartData)
this.el.chart = new Chart(el, { this.el.chart = new Chart(el, {
type: 'line', type: 'bar',
data: { data: {
datasets: [{ datasets: [{
label: el.dataset.label, label: el.dataset.label,
@ -51,13 +50,17 @@ export default {
stacked: true, stacked: true,
grace: '15%', grace: '15%',
ticks: { ticks: {
padding: 15 padding: 15,
precision: 0
} }
}, },
x: { x: {
type: 'time', type: 'timeseries',
time: { time: {
unit: 'day' unit: 'day'
},
ticks: {
source: 'data'
} }
} }
}, },

28
assets/js/slim_select.js Normal file
View File

@ -0,0 +1,28 @@
import SlimSelect from 'slim-select'
export default {
initalizeSlimSelect (el) {
// eslint-disable-next-line no-new
el.slimselect = new SlimSelect({
select: el,
settings: {
contentPosition: 'fixed'
}
})
const main = document.querySelector(`.ss-main[data-id="${el.dataset.id}"]`)
main.setAttribute('id', `${el.dataset.id}-main`)
main.setAttribute('phx-update', 'ignore')
const content = document.querySelector(`.ss-content[data-id="${el.dataset.id}"]`)
content.setAttribute('id', `${el.dataset.id}-content`)
content.setAttribute('phx-update', 'ignore')
},
updated () {
this.el.slimselect?.destroy()
this.initalizeSlimSelect(this.el)
},
mounted () {
this.initalizeSlimSelect(this.el)
}
}

23222
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,21 @@
{ {
"repository": {},
"description": " ",
"license": "MIT",
"engines": { "engines": {
"node": "v18.9.1", "node": "v23.10.0"
"npm": "8.19.1"
}, },
"scripts": { "scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin",
"format": "standard --fix", "format": "standard --fix",
"test": "standard" "test": "standard"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.7.2",
"chart.js": "^4.2.1", "chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.29.3", "date-fns": "^4.1.0",
"phoenix": "file:../deps/phoenix", "slim-select": "^2.11.0",
"phoenix_html": "file:../deps/phoenix_html", "topbar": "^3.0.0"
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.3", "npm-check-updates": "^17.1.16",
"@babel/preset-env": "^7.20.2", "standard": "^17.1.2"
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.7.5",
"npm-check-updates": "^16.7.12",
"postcss": "^8.4.21",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.0.1",
"sass": "^1.59.3",
"sass-loader": "^13.2.1",
"standard": "^17.0.0",
"tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.7",
"webpack": "^5.76.2",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.1"
} }
} }

View File

@ -1,7 +0,0 @@
module.exports = {
plugins: {
'postcss-import': {},
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,43 +0,0 @@
const colors = require('tailwindcss/colors')
module.exports = {
content: [
'../lib/**/*.{ex,heex,leex,eex}',
'./js/**/*.js'
],
theme: {
colors: {
transparent: 'transparent',
current: 'currentColor',
primary: colors.gray,
black: colors.black,
white: colors.white,
gray: colors.neutral,
indigo: colors.indigo,
red: colors.rose,
yellow: colors.amber
},
extend: {
spacing: {
128: '32rem',
192: '48rem',
256: '64rem'
},
minWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
},
maxWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
}
}
},
plugins: []
}

View File

@ -1,57 +0,0 @@
const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = (env, options) => {
const devMode = options.mode !== 'production'
return {
optimization: {
minimizer: [
new TerserPlugin({ parallel: true, extractComments: true }),
new CssMinimizerPlugin({})
]
},
entry: {
app: glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js'),
publicPath: '/js/'
},
devtool: devMode ? 'eval-cheap-module-source-map' : undefined,
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.s?css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
},
{
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource',
generator: { filename: 'fonts/[name][ext]' }
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin({ patterns: [{ from: 'static/', to: '../' }] })
]
}
}

View File

@ -8,26 +8,31 @@
import Config import Config
config :cannery, config :cannery,
ecto_repos: [Cannery.Repo], env: :dev,
generators: [binary_id: true] ecto_repos: [Cannery.Repo]
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint # Configures the endpoint
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")], http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false], render_errors: [
formats: [html: CanneryWeb.ErrorHTML, json: CanneryWeb.ErrorJSON],
layout: false
],
pubsub_server: Cannery.PubSub, pubsub_server: Cannery.PubSub,
live_view: [signing_salt: "zOLgd3lr"] live_view: [signing_salt: "zOLgd3lr"]
config :cannery, Cannery.Application, automigrate: false config :cannery, Cannery.Application, automigrate: false
config :cannery, :generators, config :cannery, :generators,
migration: true,
binary_id: true, binary_id: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111" migration: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111",
timestamp_type: :utc_datetime_usec
# Configures the mailer # Configures the mailer
# #
@ -51,14 +56,25 @@ config :cannery, Oban,
queues: [default: 10, mailers: 20] queues: [default: 10, mailers: 20]
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
# config :esbuild, config :esbuild,
# version: "0.14.0", version: "0.17.11",
# default: [ cannery: [
# args: args:
# ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
# cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
# env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
# ] ]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.0.0",
cannery: [
args: ~w(
--input=css/style.css
--output=../priv/static/assets/style.css
),
cd: Path.expand("../assets", __DIR__)
]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,

View File

@ -2,6 +2,7 @@ import Config
# Configure your database # Configure your database
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
@ -12,21 +13,14 @@ config :cannery, Cannery.Repo,
# watchers to your application. For example, we use it # watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources. # with esbuild to bundle .js and .css sources.
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 4000],
check_origin: false, check_origin: false,
code_reloader: true, code_reloader: true,
debug_errors: true, debug_errors: true,
secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
watchers: [ watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:cannery, ~w(--sourcemap=inline --watch)]},
# esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} tailwind: {Tailwind, :install_and_run, [:cannery, ~w(--watch)]}
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
cd: Path.expand("../assets", __DIR__)
]
] ]
# ## SSL Support # ## SSL Support
@ -57,10 +51,9 @@ config :cannery, CanneryWeb.Endpoint,
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
live_reload: [ live_reload: [
patterns: [ patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/cannery_web/(live|views)/.*(ex)$", ~r"lib/cannery_web/*/.*(ex)$"
~r"lib/cannery_web/templates/.*(eex)$"
] ]
] ]
@ -74,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup
debug_heex_annotations: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true

View File

@ -14,6 +14,8 @@ config :cannery, CanneryWeb.Endpoint, cache_static_manifest: "priv/static/cache_
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info
config :cannery, env: :prod
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key

View File

@ -7,12 +7,22 @@ import Config
# any compile-time configuration in here, as it won't be applied. # any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration. # The block below contains prod specific runtime configuration.
# Start the phoenix server if environment is set and running in a release # ## Using releases
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do #
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/cannery start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :cannery, CanneryWeb.Endpoint, server: true config :cannery, CanneryWeb.Endpoint, server: true
end end
config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
config :cannery, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Set default locale # Set default locale
config :gettext, :default_locale, System.get_env("LOCALE", "en_US") config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
@ -68,7 +78,7 @@ if config_env() == :prod do
System.get_env("SECRET_KEY_BASE") || System.get_env("SECRET_KEY_BASE") ||
raise """ raise """
environment variable SECRET_KEY_BASE is missing. environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: priv/random.sh
""" """
config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base

View File

@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10 timeout: 60000
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
@ -19,6 +20,8 @@ config :cannery, CanneryWeb.Endpoint,
secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK",
server: false server: false
config :cannery, env: :test
# In test we don't send emails. # In test we don't send emails.
config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
@ -26,10 +29,10 @@ config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
config :cannery, Cannery.Accounts, registration: "public" config :cannery, Cannery.Accounts, registration: "public"
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warn config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation # Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Disable Oban # Disable Oban
config :cannery, Oban, queues: false, plugins: false config :cannery, Oban, queues: false, plugins: false, testing: :manual

View File

@ -6,4 +6,35 @@ defmodule Cannery do
Contexts are also responsible for managing your data, regardless Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others. if it comes from the database, an external API or others.
""" """
def context do
quote do
use Gettext, backend: CanneryWeb.Gettext
import Ecto.Query
alias Cannery.Accounts.User
alias Cannery.Repo
alias Ecto.{Changeset, Multi, Queryable, UUID}
end
end
def schema do
quote do
use Ecto.Schema
use Gettext, backend: CanneryWeb.Gettext
import Ecto.{Changeset, Query}
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, Queryable, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@timestamps_opts [type: :utc_datetime_usec]
end
end
@doc """
When used, dispatch to the appropriate context/schema/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end end

View File

@ -3,10 +3,9 @@ defmodule Cannery.Accounts do
The Accounts context. The Accounts context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Cannery.{Mailer, Repo} alias Cannery.Mailer
alias Cannery.Accounts.{Invite, Invites, User, UserToken} alias Cannery.Accounts.{Invite, Invites, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job alias Oban.Job
## Database getters ## Database getters
@ -219,7 +218,7 @@ defmodule Cannery.Accounts do
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query), %UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do {:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok :ok
else else
_error_tuple -> :error _error_tuple -> :error
@ -374,8 +373,8 @@ defmodule Cannery.Accounts do
@doc """ @doc """
Deletes the signed token with the given context. Deletes the signed token with the given context.
""" """
@spec delete_session_token(token :: String.t()) :: :ok @spec delete_user_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do def delete_user_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all() UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok :ok
end end
@ -404,15 +403,15 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> is_admin?(%User{role: :admin}) iex> admin?(%User{role: :admin})
true true
iex> is_admin?(%User{}) iex> admin?(%User{})
false false
""" """
@spec is_admin?(User.t()) :: boolean() @spec admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do def admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
end end
@ -421,16 +420,16 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> is_already_admin?(%User{role: :admin}) iex> already_admin?(%User{role: :admin})
true true
iex> is_already_admin?(%User{}) iex> already_admin?(%User{})
false false
""" """
@spec is_already_admin?(User.t() | nil) :: boolean() @spec already_admin?(User.t() | nil) :: boolean()
def is_already_admin?(%User{role: :admin}), do: true def already_admin?(%User{role: :admin}), do: true
def is_already_admin?(_invalid_user), do: false def already_admin?(_invalid_user), do: false
## Confirmation ## Confirmation

View File

@ -5,24 +5,19 @@ defmodule Cannery.Accounts.Invite do
`:uses_left` is defined. `:uses_left` is defined.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "invites" do schema "invites" do
field :name, :string field :name, :string
field :token, :string field :token, :string
field :uses_left, :integer, default: nil field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime field :disabled_at, :utc_datetime_usec
belongs_to :created_by, User belongs_to :created_by, User
has_many :users, User has_many :users, User
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -30,12 +25,12 @@ defmodule Cannery.Accounts.Invite do
name: String.t(), name: String.t(),
token: token(), token: token(),
uses_left: integer() | nil, uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(), disabled_at: DateTime.t(),
created_by: User.t() | nil | Association.NotLoaded.t(), created_by: User.t() | nil | Association.NotLoaded.t(),
created_by_id: User.id() | nil, created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(), users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_invite :: %__MODULE__{} @type new_invite :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -3,10 +3,8 @@ defmodule Cannery.Accounts.Invites do
The Invites context. The Invites context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Ecto.Multi alias Cannery.Accounts.Invite
alias Cannery.Accounts.{Invite, User}
alias Cannery.Repo
@invite_token_length 20 @invite_token_length 20
@ -125,7 +123,7 @@ defmodule Cannery.Accounts.Invites do
end end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end end

View File

@ -3,11 +3,8 @@ defmodule Cannery.Accounts.User do
A Cannery user A Cannery user
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset alias Cannery.Accounts.Invite
import CanneryWeb.Gettext
alias Ecto.{Association, Changeset, UUID}
alias Cannery.Accounts.{Invite, User}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -20,13 +17,12 @@ defmodule Cannery.Accounts.User do
:updated_at :updated_at
]} ]}
@derive {Inspect, except: [:password]} @derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true field :password, :string, virtual: true
field :hashed_password, :string field :hashed_password, :string
field :confirmed_at, :naive_datetime field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime_usec
field :role, Ecto.Enum, values: [:admin, :user], default: :user field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string field :locale, :string
@ -34,7 +30,7 @@ defmodule Cannery.Accounts.User do
belongs_to :invite, Invite belongs_to :invite, Invite
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %User{ @type t :: %User{
@ -42,14 +38,14 @@ defmodule Cannery.Accounts.User do
email: String.t(), email: String.t(),
password: String.t(), password: String.t(),
hashed_password: String.t(), hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(), confirmed_at: DateTime.t(),
role: role(), role: role(),
locale: String.t() | nil, locale: String.t() | nil,
created_invites: [Invite.t()] | Association.NotLoaded.t(), created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(), invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil, invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_user :: %User{} @type new_user :: %User{}
@type id :: UUID.t() @type id :: UUID.t()
@ -141,7 +137,7 @@ defmodule Cannery.Accounts.User do
|> cast(attrs, [:email]) |> cast(attrs, [:email])
|> validate_email() |> validate_email()
|> case do |> case do
%{changes: %{email: _}} = changeset -> changeset %{changes: %{email: _email}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
end end
end end
@ -172,7 +168,7 @@ defmodule Cannery.Accounts.User do
""" """
@spec confirm_changeset(t() | changeset()) :: changeset() @spec confirm_changeset(t() | changeset()) :: changeset()
def confirm_changeset(user_or_changeset) do def confirm_changeset(user_or_changeset) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
user_or_changeset |> change(confirmed_at: now) user_or_changeset |> change(confirmed_at: now)
end end
@ -198,6 +194,8 @@ defmodule Cannery.Accounts.User do
""" """
@spec validate_current_password(changeset(), String.t()) :: changeset() @spec validate_current_password(changeset(), String.t()) :: changeset()
def validate_current_password(changeset, password) do def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password), if valid_password?(changeset.data, password),
do: changeset, do: changeset,
else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))

View File

@ -3,10 +3,7 @@ defmodule Cannery.Accounts.UserToken do
Schema for a user's session token Schema for a user's session token
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Query
alias Cannery.Accounts.User
alias Ecto.{Association, UUID}
@hash_algorithm :sha256 @hash_algorithm :sha256
@rand_size 32 @rand_size 32
@ -18,8 +15,6 @@ defmodule Cannery.Accounts.UserToken do
@change_email_validity_in_days 7 @change_email_validity_in_days 7
@session_validity_in_days 60 @session_validity_in_days 60
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do schema "users_tokens" do
field :token, :binary field :token, :binary
field :context, :string field :context, :string
@ -37,7 +32,7 @@ defmodule Cannery.Accounts.UserToken do
sent_to: String.t(), sent_to: String.t(),
user: User.t() | Association.NotLoaded.t(), user: User.t() | Association.NotLoaded.t(),
user_id: User.id() | nil, user_id: User.id() | nil,
inserted_at: NaiveDateTime.t() inserted_at: DateTime.t()
} }
@type new_user_token :: %__MODULE__{} @type new_user_token :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@ -155,7 +150,7 @@ defmodule Cannery.Accounts.UserToken do
from t in __MODULE__, where: t.user_id == ^user.id from t in __MODULE__, where: t.user_id == ^user.id
end end
def user_and_contexts_query(user, [_ | _] = contexts) do def user_and_contexts_query(user, [_first | _rest] = contexts) do
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end end
end end

View File

@ -3,378 +3,453 @@ defmodule Cannery.ActivityLog do
The ActivityLog context. The ActivityLog context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Cannery.Ammo.{AmmoGroup, AmmoType} alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.{Multi, Queryable} @type list_shot_records_option ::
{:search, String.t() | nil}
| {:class, Type.class() | :all | nil}
| {:start_date, String.t() | nil}
| {:end_date, String.t() | nil}
| {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()]
@doc """ @doc """
Returns the list of shot_groups. Returns the list of shot_records.
## Examples ## Examples
iex> list_shot_groups(:all, %User{id: 123}) iex> list_shot_records(%User{id: 123})
[%ShotGroup{}, ...] [%ShotRecord{}, ...]
iex> list_shot_groups("cool", :all, %User{id: 123}) iex> list_shot_records(%User{id: 123}, search: "cool")
[%ShotGroup{notes: "My cool shot group"}, ...] [%ShotRecord{notes: "My cool shot record"}, ...]
iex> list_shot_groups("cool", :rifle, %User{id: 123}) iex> list_shot_records(%User{id: 123}, search: "cool", class: :rifle)
[%ShotGroup{notes: "Shot some rifle rounds"}, ...] [%ShotRecord{notes: "Shot some rifle rounds"}, ...]
iex> list_shot_records(%User{id: 123}, pack_id: 456)
[%ShotRecord{pack_id: 456}, ...]
""" """
@spec list_shot_groups(AmmoType.type() | :all, User.t()) :: [ShotGroup.t()] @spec list_shot_records(User.t()) :: [ShotRecord.t()]
@spec list_shot_groups(search :: nil | String.t(), AmmoType.type() | :all, User.t()) :: @spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()]
[ShotGroup.t()] def list_shot_records(%User{id: user_id}, opts \\ []) do
def list_shot_groups(search \\ nil, type, %{id: user_id}) do from(sr in ShotRecord,
from(sg in ShotGroup, as: :sr,
as: :sg, left_join: p in Pack,
left_join: ag in AmmoGroup, as: :p,
as: :ag, on: sr.pack_id == p.id,
on: sg.ammo_group_id == ag.id, on: p.user_id == ^user_id,
left_join: at in AmmoType, left_join: t in Type,
as: :at, as: :t,
on: ag.ammo_type_id == at.id, on: p.type_id == t.id,
where: sg.user_id == ^user_id, on: t.user_id == ^user_id,
distinct: sg.id where: sr.user_id == ^user_id,
distinct: sr.id
) )
|> list_shot_groups_search(search) |> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_groups_filter_type(type) |> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> list_shot_records_start_date(Keyword.get(opts, :start_date))
|> list_shot_records_end_date(Keyword.get(opts, :end_date))
|> Repo.all() |> Repo.all()
end end
@spec list_shot_groups_search(Queryable.t(), search :: String.t() | nil) :: @spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) ::
Queryable.t() Queryable.t()
defp list_shot_groups_search(query, search) when search in ["", nil], do: query defp list_shot_records_search(query, search) when search in ["", nil], do: query
defp list_shot_groups_search(query, search) when search |> is_binary() do defp list_shot_records_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
query query
|> where( |> where(
[sg: sg, ag: ag, at: at], [sr: sr, p: p, t: t],
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
sg.search, sr.search,
^trimmed_search ^trimmed_search
) or ) or
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
ag.search, p.search,
^trimmed_search ^trimmed_search
) or ) or
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
at.search, t.search,
^trimmed_search ^trimmed_search
) )
) )
|> order_by([sg: sg], { |> order_by([sr: sr], {
:desc, :desc,
fragment( fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sg.search, sr.search,
^trimmed_search ^trimmed_search
) )
}) })
end end
@spec list_shot_groups_filter_type(Queryable.t(), AmmoType.type() | :all) :: @spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t()
Queryable.t() defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun],
defp list_shot_groups_filter_type(query, :rifle), do: query |> where([t: t], t.class == ^class)
do: query |> where([at: at], at.type == :rifle)
defp list_shot_groups_filter_type(query, :pistol), defp list_shot_records_class(query, _all), do: query
do: query |> where([at: at], at.type == :pistol)
defp list_shot_groups_filter_type(query, :shotgun), @spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
do: query |> where([at: at], at.type == :shotgun) defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(),
do: query |> where([sr: sr], sr.pack_id == ^pack_id)
defp list_shot_groups_filter_type(query, _all), do: query defp list_shot_records_pack_id(query, _all), do: query
@spec list_shot_groups_for_ammo_group(AmmoGroup.t(), User.t()) :: [ShotGroup.t()] @spec list_shot_records_start_date(Queryable.t(), String.t() | nil) :: Queryable.t()
def list_shot_groups_for_ammo_group( defp list_shot_records_start_date(query, start_date) when start_date |> is_binary() do
%AmmoGroup{id: ammo_group_id, user_id: user_id}, query |> where([sr: sr], sr.date >= ^Date.from_iso8601!(start_date))
%User{id: user_id}
) do
Repo.all(
from sg in ShotGroup,
where: sg.ammo_group_id == ^ammo_group_id,
where: sg.user_id == ^user_id
)
end end
@doc """ defp list_shot_records_start_date(query, _all), do: query
Gets a single shot_group.
Raises `Ecto.NoResultsError` if the Shot group does not exist. @spec list_shot_records_end_date(Queryable.t(), String.t() | nil) :: Queryable.t()
defp list_shot_records_end_date(query, end_date) when end_date |> is_binary() do
query |> where([sr: sr], sr.date <= ^Date.from_iso8601!(end_date))
end
defp list_shot_records_end_date(query, _all), do: query
@doc """
Returns a count of shot records.
## Examples ## Examples
iex> get_shot_group!(123, %User{id: 123}) iex> get_shot_record_count!(%User{id: 123})
%ShotGroup{} 3
iex> get_shot_group!(456, %User{id: 123}) """
@spec get_shot_record_count!(User.t()) :: integer()
def get_shot_record_count!(%User{id: user_id}) do
Repo.one(
from sr in ShotRecord,
where: sr.user_id == ^user_id,
select: count(sr.id),
distinct: true
) || 0
end
@doc """
Gets a single shot_record.
Raises `Ecto.NoResultsError` if the shot record does not exist.
## Examples
iex> get_shot_record!(123, %User{id: 123})
%ShotRecord{}
iex> get_shot_record!(456, %User{id: 123})
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_shot_group!(ShotGroup.id(), User.t()) :: ShotGroup.t() @spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t()
def get_shot_group!(id, %User{id: user_id}) do def get_shot_record!(id, %User{id: user_id}) do
Repo.one!( Repo.one!(
from sg in ShotGroup, from sr in ShotRecord,
where: sg.id == ^id, where: sr.id == ^id,
where: sg.user_id == ^user_id, where: sr.user_id == ^user_id,
order_by: sg.date order_by: sr.date
) )
end end
@doc """ @doc """
Creates a shot_group. Creates a shot_record.
## Examples ## Examples
iex> create_shot_group(%{field: value}, %User{id: 123}) iex> create_shot_record(%{field: value}, %User{id: 123})
{:ok, %ShotGroup{}} {:ok, %ShotRecord{}}
iex> create_shot_group(%{field: bad_value}, %User{id: 123}) iex> create_shot_record(%{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) :: @spec create_shot_record(attrs :: map(), User.t(), Pack.t()) ::
{:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil}
def create_shot_group(attrs, user, ammo_group) do def create_shot_record(attrs, user, pack) do
Multi.new() Multi.new()
|> Multi.insert( |> Multi.insert(
:create_shot_group, :create_shot_record,
%ShotGroup{} |> ShotGroup.create_changeset(user, ammo_group, attrs) %ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs)
) )
|> Multi.run( |> Multi.run(
:ammo_group, :pack,
fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
ammo_group = pack =
Repo.one( Repo.one(
from ag in AmmoGroup, from p in Pack,
where: ag.id == ^ammo_group_id, where: p.id == ^pack_id,
where: ag.user_id == ^user_id where: p.user_id == ^user_id
) )
{:ok, ammo_group} {:ok, pack}
end end
) )
|> Multi.update( |> Multi.update(
:update_ammo_group, :update_pack,
fn %{create_shot_group: %{count: shot_group_count}, ammo_group: %{count: ammo_group_count}} -> fn %{create_shot_record: %{count: shot_record_count}, pack: %{count: pack_count}} ->
ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count}) pack |> Pack.range_changeset(%{"count" => pack_count - shot_record_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group} {:ok, %{create_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset} {:error, :create_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Updates a shot_group. Updates a shot_record.
## Examples ## Examples
iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123}) iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123})
{:ok, %ShotGroup{}} {:ok, %ShotRecord{}}
iex> update_shot_group(shot_group, %{field: bad_value}, %User{id: 123}) iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) :: @spec update_shot_record(ShotRecord.t(), attrs :: map(), User.t()) ::
{:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil}
def update_shot_group( def update_shot_record(
%ShotGroup{count: count, user_id: user_id} = shot_group, %ShotRecord{count: count, user_id: user_id} = shot_record,
attrs, attrs,
%User{id: user_id} = user %User{id: user_id} = user
) do ) do
Multi.new() Multi.new()
|> Multi.update( |> Multi.update(
:update_shot_group, :update_shot_record,
shot_group |> ShotGroup.update_changeset(user, attrs) shot_record |> ShotRecord.update_changeset(user, attrs)
) )
|> Multi.run( |> Multi.run(
:ammo_group, :pack,
fn repo, %{update_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, {:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
end end
) )
|> Multi.update( |> Multi.update(
:update_ammo_group, :update_pack,
fn %{ fn %{
update_shot_group: %{count: new_count}, update_shot_record: %{count: new_count},
ammo_group: %{count: ammo_group_count} = ammo_group pack: %{count: pack_count} = pack
} -> } ->
shot_diff_to_add = new_count - count shot_diff_to_add = new_count - count
new_ammo_group_count = ammo_group_count - shot_diff_to_add new_pack_count = pack_count - shot_diff_to_add
ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count}) pack |> Pack.range_changeset(%{"count" => new_pack_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group} {:ok, %{update_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset} {:error, :update_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Deletes a shot_group. Deletes a shot_record.
## Examples ## Examples
iex> delete_shot_group(shot_group, %User{id: 123}) iex> delete_shot_record(shot_record, %User{id: 123})
{:ok, %ShotGroup{}} {:ok, %ShotRecord{}}
iex> delete_shot_group(shot_group, %User{id: 123}) iex> delete_shot_record(shot_record, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec delete_shot_group(ShotGroup.t(), User.t()) :: @spec delete_shot_record(ShotRecord.t(), User.t()) ::
{:ok, ShotGroup.t()} | {:error, ShotGroup.changeset()} {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset()}
def delete_shot_group( def delete_shot_record(
%ShotGroup{user_id: user_id} = shot_group, %ShotRecord{user_id: user_id} = shot_record,
%User{id: user_id} %User{id: user_id}
) do ) do
Multi.new() Multi.new()
|> Multi.delete(:delete_shot_group, shot_group) |> Multi.delete(:delete_shot_record, shot_record)
|> Multi.run( |> Multi.run(
:ammo_group, :pack,
fn repo, %{delete_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, {:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
end end
) )
|> Multi.update( |> Multi.update(
:update_ammo_group, :update_pack,
fn %{ fn %{
delete_shot_group: %{count: count}, delete_shot_record: %{count: count},
ammo_group: %{count: ammo_group_count} = ammo_group pack: %{count: pack_count} = pack
} -> } ->
new_ammo_group_count = ammo_group_count + count new_pack_count = pack_count + count
ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count}) pack |> Pack.range_changeset(%{"count" => new_pack_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{delete_shot_group: shot_group}} -> {:ok, shot_group} {:ok, %{delete_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :delete_shot_group, changeset, _changes_so_far} -> {:error, changeset} {:error, :delete_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Returns the number of shot rounds for an ammo group Returns the last entered shot record date for a pack
""" """
@spec get_used_count(AmmoGroup.t(), User.t()) :: non_neg_integer() @spec get_last_used_date(Pack.t(), User.t()) :: Date.t() | nil
def get_used_count(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do def get_last_used_date(%Pack{id: pack_id} = pack, user) do
[ammo_group] [pack]
|> get_used_counts(user)
|> Map.get(ammo_group_id, 0)
end
@doc """
Returns the number of shot rounds for multiple ammo groups
"""
@spec get_used_counts([AmmoGroup.t()], User.t()) ::
%{optional(AmmoGroup.id()) => non_neg_integer()}
def get_used_counts(ammo_groups, %User{id: user_id}) do
ammo_group_ids =
ammo_groups
|> Enum.map(fn %{id: ammo_group_id} -> ammo_group_id end)
Repo.all(
from sg in ShotGroup,
where: sg.ammo_group_id in ^ammo_group_ids,
where: sg.user_id == ^user_id,
group_by: sg.ammo_group_id,
select: {sg.ammo_group_id, sum(sg.count)}
)
|> Map.new()
end
@doc """
Returns the last entered shot group date for an ammo group
"""
@spec get_last_used_date(AmmoGroup.t(), User.t()) :: Date.t() | nil
def get_last_used_date(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do
[ammo_group]
|> get_last_used_dates(user) |> get_last_used_dates(user)
|> Map.get(ammo_group_id) |> Map.get(pack_id)
end end
@doc """ @doc """
Returns the last entered shot group date for an ammo group Returns the last entered shot record date for a pack
""" """
@spec get_last_used_dates([AmmoGroup.t()], User.t()) :: %{optional(AmmoGroup.id()) => Date.t()} @spec get_last_used_dates([Pack.t()], User.t()) :: %{optional(Pack.id()) => Date.t()}
def get_last_used_dates(ammo_groups, %User{id: user_id}) do def get_last_used_dates(packs, %User{id: user_id}) do
ammo_group_ids = pack_ids =
ammo_groups packs
|> Enum.map(fn %AmmoGroup{id: ammo_group_id, user_id: ^user_id} -> ammo_group_id end) |> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end)
Repo.all( Repo.all(
from sg in ShotGroup, from sr in ShotRecord,
where: sg.ammo_group_id in ^ammo_group_ids, where: sr.pack_id in ^pack_ids,
where: sg.user_id == ^user_id, where: sr.user_id == ^user_id,
group_by: sg.ammo_group_id, group_by: sr.pack_id,
select: {sg.ammo_group_id, max(sg.date)} select: {sr.pack_id, max(sr.date)}
) )
|> Map.new() |> Map.new()
end end
@doc """ @type get_used_count_option :: {:pack_id, Pack.id() | nil} | {:type_id, Type.id() | nil}
Gets the total number of rounds shot for an ammo type @type get_used_count_options :: [get_used_count_option()]
Raises `Ecto.NoResultsError` if the Ammo type does not exist. @doc """
Gets the total number of rounds shot for a type
Raises `Ecto.NoResultsError` if the type does not exist.
## Examples ## Examples
iex> get_used_count_for_ammo_type(123, %User{id: 123}) iex> get_used_count(%User{id: 123}, type_id: 123)
35 35
iex> get_used_count_for_ammo_type(456, %User{id: 123}) iex> get_used_count(%User{id: 123}, pack_id: 456)
** (Ecto.NoResultsError) 50
""" """
@spec get_used_count_for_ammo_type(AmmoType.t(), User.t()) :: non_neg_integer() @spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer()
def get_used_count_for_ammo_type(%AmmoType{id: ammo_type_id} = ammo_type, user) do def get_used_count(%User{id: user_id}, opts) do
[ammo_type] from(sr in ShotRecord,
|> get_used_count_for_ammo_types(user) as: :sr,
|> Map.get(ammo_type_id, 0) left_join: p in Pack,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
as: :p,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil()),
select: sum(sr.count),
distinct: true
)
|> get_used_count_type_id(Keyword.get(opts, :type_id))
|> get_used_count_pack_id(Keyword.get(opts, :pack_id))
|> Repo.one() || 0
end end
@spec get_used_count_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp get_used_count_pack_id(query, pack_id) when pack_id |> is_binary() do
query |> where([sr: sr], sr.pack_id == ^pack_id)
end
defp get_used_count_pack_id(query, _nil), do: query
@spec get_used_count_type_id(Queryable.t(), Type.id() | nil) :: Queryable.t()
defp get_used_count_type_id(query, type_id) when type_id |> is_binary() do
query |> where([p: p], p.type_id == ^type_id)
end
defp get_used_count_type_id(query, _nil), do: query
@type get_grouped_used_counts_option ::
{:packs, [Pack.t()] | nil}
| {:types, [Type.t()] | nil}
| {:group_by, :type_id | :pack_id}
@type get_grouped_used_counts_options :: [get_grouped_used_counts_option()]
@doc """ @doc """
Gets the total number of rounds shot for multiple ammo types Gets the total number of rounds shot for multiple types or packs
## Examples ## Examples
iex> get_used_count_for_ammo_types(123, %User{id: 123}) iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :type_id,
...> types: [%Type{id: 456, user_id: 123}]
...> )
35 35
""" iex> get_grouped_used_counts(
@spec get_used_count_for_ammo_types([AmmoType.t()], User.t()) :: ...> %User{id: 123},
%{optional(AmmoType.id()) => non_neg_integer()} ...> group_by: :pack_id,
def get_used_count_for_ammo_types(ammo_types, %User{id: user_id}) do ...> packs: [%Pack{id: 456, user_id: 123}]
ammo_type_ids = ...> )
ammo_types 22
|> Enum.map(fn %AmmoType{id: ammo_type_id, user_id: ^user_id} -> ammo_type_id end)
Repo.all( """
from ag in AmmoGroup, @spec get_grouped_used_counts(User.t(), get_grouped_used_counts_options()) ::
left_join: sg in ShotGroup, %{optional(Type.id() | Pack.id()) => non_neg_integer()}
on: ag.id == sg.ammo_group_id, def get_grouped_used_counts(%User{id: user_id}, opts) do
where: ag.ammo_type_id in ^ammo_type_ids, from(p in Pack,
where: not (sg.count |> is_nil()), as: :p,
group_by: ag.ammo_type_id, left_join: sr in ShotRecord,
select: {ag.ammo_type_id, sum(sg.count)} on: p.id == sr.pack_id,
on: p.user_id == ^user_id,
as: :sr,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil())
) )
|> get_grouped_used_counts_group_by(Keyword.fetch!(opts, :group_by))
|> get_grouped_used_counts_types(Keyword.get(opts, :types))
|> get_grouped_used_counts_packs(Keyword.get(opts, :packs))
|> Repo.all()
|> Map.new() |> Map.new()
end end
@spec get_grouped_used_counts_group_by(Queryable.t(), :type_id | :pack_id) :: Queryable.t()
defp get_grouped_used_counts_group_by(query, :type_id) do
query
|> group_by([p: p], p.type_id)
|> select([sr: sr, p: p], {p.type_id, sum(sr.count)})
end
defp get_grouped_used_counts_group_by(query, :pack_id) do
query
|> group_by([sr: sr], sr.pack_id)
|> select([sr: sr], {sr.pack_id, sum(sr.count)})
end
@spec get_grouped_used_counts_types(Queryable.t(), [Type.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_types(query, types) when types |> is_list() do
type_ids = types |> Enum.map(fn %Type{id: type_id} -> type_id end)
query |> where([p: p], p.type_id in ^type_ids)
end
defp get_grouped_used_counts_types(query, _nil), do: query
@spec get_grouped_used_counts_packs(Queryable.t(), [Pack.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_packs(query, packs) when packs |> is_list() do
pack_ids = packs |> Enum.map(fn %Pack{id: pack_id} -> pack_id end)
query |> where([p: p], p.id in ^pack_ids)
end
defp get_grouped_used_counts_packs(query, _nil), do: query
end end

View File

@ -1,129 +0,0 @@
defmodule Cannery.ActivityLog.ShotGroup do
@moduledoc """
A shot group records a group of ammo shot during a range trip
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:id,
:count,
:date,
:notes,
:ammo_group_id
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shot_groups" do
field :count, :integer
field :date, :date
field :notes, :string
field :user_id, :binary_id
field :ammo_group_id, :binary_id
timestamps()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
ammo_group_id: AmmoGroup.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_shot_group :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_group())
@doc false
@spec create_changeset(
new_shot_group(),
User.t() | any(),
AmmoGroup.t() | any(),
attrs :: map()
) :: changeset()
def create_changeset(
shot_group,
%User{id: user_id},
%AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group,
attrs
) do
shot_group
|> change(user_id: user_id)
|> change(ammo_group_id: ammo_group_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_create_shot_group_count(ammo_group)
|> validate_required([:date, :ammo_group_id, :user_id])
end
def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_required([:ammo_group_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end
defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do
case changeset |> Changeset.get_field(:count) do
nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > ammo_group_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds",
count: ammo_group_count - 1
)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset()
def update_changeset(%__MODULE__{} = shot_group, user, attrs) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date])
|> validate_update_shot_group_count(shot_group, user)
end
defp validate_update_shot_group_count(
changeset,
%__MODULE__{ammo_group_id: ammo_group_id, count: count},
user
) do
%{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user)
new_shot_group_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_group_count - count
if shot_diff_to_add > ammo_group_count do
error =
dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count)
changeset |> Changeset.add_error(:count, error)
else
changeset
end
end
end

View File

@ -0,0 +1,123 @@
defmodule Cannery.ActivityLog.ShotRecord do
@moduledoc """
A shot record records a group of ammo shot during a range trip
"""
use Cannery, :schema
alias Cannery.{Ammo, Ammo.Pack}
@derive {Jason.Encoder,
only: [
:id,
:count,
:date,
:notes,
:pack_id
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shot_records" do
field :count, :integer
field :date, :date
field :notes, :string
field :user_id, :binary_id
field :pack_id, :binary_id
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
pack_id: Pack.id(),
user_id: User.id(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type new_shot_record :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_record())
@doc false
@spec create_changeset(
new_shot_record(),
User.t() | any(),
Pack.t() | any(),
attrs :: map()
) :: changeset()
def create_changeset(
shot_record,
%User{id: user_id},
%Pack{id: pack_id, user_id: user_id} = pack,
attrs
) do
shot_record
|> change(user_id: user_id)
|> change(pack_id: pack_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_create_shot_record_count(pack)
|> validate_required([:date, :pack_id, :user_id])
end
def create_changeset(shot_record, _invalid_user, _invalid_pack, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_required([:pack_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end
defp validate_create_shot_record_count(changeset, %Pack{count: pack_count}) do
case changeset |> Changeset.get_field(:count) do
nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > pack_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds", count: pack_count - 1)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_record(), User.t(), attrs :: map()) :: changeset()
def update_changeset(%__MODULE__{} = shot_record, user, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date])
|> validate_update_shot_record_count(shot_record, user)
end
defp validate_update_shot_record_count(
changeset,
%__MODULE__{pack_id: pack_id, count: count},
user
) do
%{count: pack_count} = Ammo.get_pack!(pack_id, user)
new_shot_record_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_record_count - count
if shot_diff_to_add > pack_count do
error = dgettext("errors", "Count can be at most %{count} shots", count: pack_count + count)
changeset |> Changeset.add_error(:count, error)
else
changeset
end
end
end

File diff suppressed because it is too large Load Diff

View File

@ -1,125 +0,0 @@
defmodule Cannery.Ammo.AmmoGroup do
@moduledoc """
A group of a certain ammunition type.
Can be placed in a container, and contains auxiliary information such as the
amount paid for that ammunition, or what condition it is in
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.Ammo.AmmoType
alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:id,
:count,
:notes,
:price_paid,
:staged,
:ammo_type_id,
:container_id
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "ammo_groups" do
field :count, :integer
field :notes, :string
field :price_paid, :float
field :staged, :boolean, default: false
field :purchased_on, :date
belongs_to :ammo_type, AmmoType
field :container_id, :binary_id
field :user_id, :binary_id
timestamps()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
price_paid: float() | nil,
staged: boolean(),
purchased_on: Date.t(),
ammo_type: AmmoType.t() | nil,
ammo_type_id: AmmoType.id(),
container_id: Container.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_group :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_group())
@doc false
@spec create_changeset(
new_ammo_group(),
AmmoType.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: changeset()
def create_changeset(
ammo_group,
%AmmoType{id: ammo_type_id},
%Container{id: container_id, user_id: user_id},
%User{id: user_id},
attrs
)
when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do
ammo_group
|> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id)
|> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on])
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :staged, :purchased_on, :ammo_type_id, :container_id, :user_id])
end
@doc """
Invalid changeset, used to prompt user to select ammo type and container
"""
def create_changeset(ammo_group, _invalid_ammo_type, _invalid_container, _invalid_user, attrs) do
ammo_group
|> cast(attrs, [:ammo_type_id, :container_id])
|> validate_required([:ammo_type_id, :container_id])
|> add_error(:invalid, dgettext("errors", "Please select an ammo type and container"))
end
@doc false
@spec update_changeset(t() | new_ammo_group(), attrs :: map(), User.t()) :: changeset()
def update_changeset(ammo_group, attrs, user) do
ammo_group
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :container_id])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_container_id(user)
|> validate_required([:count, :staged, :purchased_on, :container_id])
end
defp validate_container_id(changeset, user) do
container_id = changeset |> Changeset.get_field(:container_id)
if container_id do
Containers.get_container!(container_id, user)
end
changeset
end
@doc """
This range changeset is used when "using up" ammo groups, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: changeset()
def range_changeset(ammo_group, attrs) do
ammo_group
|> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged])
end
end

158
lib/cannery/ammo/pack.ex Normal file
View File

@ -0,0 +1,158 @@
defmodule Cannery.Ammo.Pack do
@moduledoc """
A group of a certain ammunition type.
Can be placed in a container, and contains auxiliary information such as the
amount paid for that ammunition, or what condition it is in
"""
use Cannery, :schema
alias Cannery.{Ammo.Type, Containers, Containers.Container}
@derive {Jason.Encoder,
only: [
:container_id,
:count,
:id,
:lot_number,
:notes,
:price_paid,
:type_id
]}
schema "packs" do
field :count, :integer
field :lot_number, :string
field :notes, :string
field :price_paid, :float
field :purchased_on, :date
belongs_to :type, Type
field :container_id, :binary_id
field :user_id, :binary_id
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
count: integer,
id: id(),
lot_number: String.t() | nil,
notes: String.t() | nil,
price_paid: float() | nil,
purchased_on: Date.t(),
type: Type.t() | nil,
type_id: Type.id(),
container_id: Container.id(),
user_id: User.id(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type new_pack :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_pack())
@doc false
@spec create_changeset(
new_pack(),
Type.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: changeset()
def create_changeset(
pack,
type,
container,
%User{id: user_id},
attrs
)
when is_binary(user_id) do
type_id =
case type do
%Type{id: type_id} when is_binary(type_id) ->
type_id
_invalid_type ->
nil
end
container_id =
case container do
%Container{id: container_id, user_id: ^user_id} when is_binary(container_id) ->
container_id
_invalid_container ->
nil
end
pack
|> change(type_id: type_id)
|> change(container_id: container_id)
|> change(user_id: user_id)
|> cast(attrs, [
:count,
:lot_number,
:notes,
:price_paid,
:purchased_on
])
|> validate_required(:type_id, message: dgettext("errors", "Please select a valid type"))
|> validate_required(:container_id,
message: dgettext("errors", "Please select a valid container")
)
|> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_length(:lot_number, max: 255)
|> validate_required([
:container_id,
:count,
:purchased_on,
:type_id,
:user_id
])
end
@doc false
@spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset()
def update_changeset(pack, attrs, user) do
pack
|> cast(attrs, [
:container_id,
:count,
:lot_number,
:notes,
:price_paid,
:purchased_on
])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_container_id(user)
|> validate_length(:lot_number, max: 255)
|> validate_required([
:container_id,
:count,
:purchased_on
])
end
defp validate_container_id(changeset, user) do
container_id = changeset |> Changeset.get_field(:container_id)
if container_id do
Containers.get_container!(container_id, user)
end
changeset
end
@doc """
This range changeset is used when "using up" packs, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset()
def range_changeset(pack, attrs) do
pack
|> cast(attrs, [:count])
|> validate_required([:count])
end
end

View File

@ -1,52 +1,55 @@
defmodule Cannery.Ammo.AmmoType do defmodule Cannery.Ammo.Type do
@moduledoc """ @moduledoc """
An ammunition type. An ammunition type.
Contains statistical information about the ammunition. Contains statistical information about the ammunition.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset alias Cannery.Ammo.Pack
alias Cannery.Accounts.User
alias Cannery.Ammo.AmmoGroup
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
:id,
:name, :name,
:desc, :desc,
:class,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge,
:caliber, :caliber,
:case_material, :case_material,
:jacket_type,
:muzzle_velocity,
:powder_type, :powder_type,
:powder_grains_per_charge,
:grains, :grains,
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:manufacturer,
:upc,
:tracer, :tracer,
:incendiary, :incendiary,
:blank, :blank,
:corrosive, :corrosive,
:manufacturer, :cartridge,
:upc :jacket_type,
:powder_grains_per_charge,
:muzzle_velocity,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:load_grains,
:shot_charge_weight,
:dram_equivalent
]} ]}
@primary_key {:id, :binary_id, autogenerate: true} schema "types" do
@foreign_key_type :binary_id
schema "ammo_types" do
field :name, :string field :name, :string
field :desc, :string field :desc, :string
field :type, Ecto.Enum, values: [:rifle, :shotgun, :pistol] field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol], default: :rifle
# common fields # common fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string
field :bullet_core, :string field :bullet_core, :string
# also gauge for shotguns # also gauge for shotguns
field :caliber, :string field :caliber, :string
@ -65,6 +68,8 @@ defmodule Cannery.Ammo.AmmoType do
field :corrosive, :boolean, default: false field :corrosive, :boolean, default: false
# rifle/pistol fields # rifle/pistol fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string
field :cartridge, :string field :cartridge, :string
field :jacket_type, :string field :jacket_type, :string
field :powder_grains_per_charge, :integer field :powder_grains_per_charge, :integer
@ -83,29 +88,35 @@ defmodule Cannery.Ammo.AmmoType do
field :dram_equivalent, :string field :dram_equivalent, :string
field :user_id, :binary_id field :user_id, :binary_id
has_many :ammo_groups, AmmoGroup has_many :packs, Pack
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: id(), id: id(),
name: String.t(), name: String.t(),
desc: String.t() | nil, desc: String.t() | nil,
type: type(), class: class(),
bullet_type: String.t() | nil, bullet_type: String.t() | nil,
bullet_core: String.t() | nil, bullet_core: String.t() | nil,
cartridge: String.t() | nil,
caliber: String.t() | nil, caliber: String.t() | nil,
case_material: String.t() | nil, case_material: String.t() | nil,
jacket_type: String.t() | nil,
muzzle_velocity: integer() | nil,
powder_type: String.t() | nil, powder_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
grains: integer() | nil, grains: integer() | nil,
pressure: String.t() | nil, pressure: String.t() | nil,
primer_type: String.t() | nil, primer_type: String.t() | nil,
firing_type: String.t() | nil, firing_type: String.t() | nil,
manufacturer: String.t() | nil,
upc: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
cartridge: String.t() | nil,
jacket_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
muzzle_velocity: integer() | nil,
wadding: String.t() | nil, wadding: String.t() | nil,
shot_type: String.t() | nil, shot_type: String.t() | nil,
shot_material: String.t() | nil, shot_material: String.t() | nil,
@ -116,41 +127,41 @@ defmodule Cannery.Ammo.AmmoType do
load_grains: integer() | nil, load_grains: integer() | nil,
shot_charge_weight: String.t() | nil, shot_charge_weight: String.t() | nil,
dram_equivalent: String.t() | nil, dram_equivalent: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
manufacturer: String.t() | nil,
upc: String.t() | nil,
user_id: User.id(), user_id: User.id(),
ammo_groups: [AmmoGroup.t()] | nil, packs: [Pack.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_ammo_type :: %__MODULE__{} @type new_type :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_type()) @type changeset :: Changeset.t(t() | new_type())
@type type :: :rifle | :shotgun | :pistol | nil @type class :: :rifle | :shotgun | :pistol | nil
@spec changeset_fields() :: [atom()] @spec changeset_fields() :: [atom()]
defp changeset_fields, defp changeset_fields,
do: [ do: [
:name, :name,
:desc, :desc,
:type, :class,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge,
:caliber, :caliber,
:case_material, :case_material,
:jacket_type,
:muzzle_velocity,
:powder_type, :powder_type,
:powder_grains_per_charge,
:grains, :grains,
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:manufacturer,
:upc,
:tracer,
:incendiary,
:blank,
:corrosive,
:cartridge,
:jacket_type,
:powder_grains_per_charge,
:muzzle_velocity,
:wadding, :wadding,
:shot_type, :shot_type,
:shot_material, :shot_material,
@ -160,29 +171,26 @@ defmodule Cannery.Ammo.AmmoType do
:chamber_size, :chamber_size,
:load_grains, :load_grains,
:shot_charge_weight, :shot_charge_weight,
:dram_equivalent, :dram_equivalent
:tracer,
:incendiary,
:blank,
:corrosive,
:manufacturer,
:upc
] ]
@spec string_fields() :: [atom()] @spec string_fields() :: [atom()]
defp string_fields, defp string_fields,
do: [ do: [
:name, :name,
:desc,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge,
:caliber, :caliber,
:case_material, :case_material,
:jacket_type,
:powder_type, :powder_type,
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:manufacturer,
:upc,
:cartridge,
:jacket_type,
:wadding, :wadding,
:shot_type, :shot_type,
:shot_material, :shot_material,
@ -191,16 +199,14 @@ defmodule Cannery.Ammo.AmmoType do
:brass_height, :brass_height,
:chamber_size, :chamber_size,
:shot_charge_weight, :shot_charge_weight,
:dram_equivalent, :dram_equivalent
:manufacturer,
:upc
] ]
@doc false @doc false
@spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset() @spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(ammo_type, %User{id: user_id}, attrs) do def create_changeset(type, %User{id: user_id}, attrs) do
changeset = changeset =
ammo_type type
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
@ -210,10 +216,10 @@ defmodule Cannery.Ammo.AmmoType do
end end
@doc false @doc false
@spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_type(), attrs :: map()) :: changeset()
def update_changeset(ammo_type, attrs) do def update_changeset(type, attrs) do
changeset = changeset =
ammo_type type
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
string_fields() string_fields()

View File

@ -15,6 +15,7 @@ defmodule Cannery.Application do
CanneryWeb.Telemetry, CanneryWeb.Telemetry,
# Start the PubSub system # Start the PubSub system
{Phoenix.PubSub, name: Cannery.PubSub}, {Phoenix.PubSub, name: Cannery.PubSub},
{DNSCluster, query: Application.get_env(:cannery, :dns_cluster_query) || :ignore},
# Start the Endpoint (http/https) # Start the Endpoint (http/https)
CanneryWeb.Endpoint, CanneryWeb.Endpoint,
# Add Oban # Add Oban

View File

@ -3,14 +3,17 @@ defmodule Cannery.Containers do
The Containers context. The Containers context.
""" """
import CanneryWeb.Gettext use Cannery, :context
import Ecto.Query, warn: false alias Cannery.Ammo.Pack
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag} alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.Changeset
@container_preloads [:tags] @container_preloads [:tags]
@type list_containers_option ::
{:search, String.t() | nil}
| {:staged, boolean() | nil}
@type list_containers_options :: [list_containers_option()]
@doc """ @doc """
Returns the list of containers. Returns the list of containers.
@ -19,30 +22,41 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123}) iex> list_containers(%User{id: 123})
[%Container{}, ...] [%Container{}, ...]
iex> list_containers("cool", %User{id: 123}) iex> list_containers(%User{id: 123},
...> search: "cool",
...> staged: true
...> )
[%Container{name: "my cool container"}, ...] [%Container{name: "my cool container"}, ...]
""" """
@spec list_containers(User.t()) :: [Container.t()] @spec list_containers(User.t()) :: [Container.t()]
@spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()] @spec list_containers(User.t(), list_containers_options()) :: [Container.t()]
def list_containers(search \\ nil, %User{id: user_id}) do def list_containers(%User{id: user_id}, opts \\ []) do
from(c in Container, from(c in Container,
as: :c, as: :c,
left_join: t in assoc(c, :tags), left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t, as: :t,
where: c.user_id == ^user_id, where: c.user_id == ^user_id,
order_by: c.name,
distinct: c.id, distinct: c.id,
preload: ^@container_preloads preload: ^@container_preloads
) )
|> list_containers_search(search) |> list_containers_search(Keyword.get(opts, :search))
|> list_containers_staged(Keyword.get(opts, :staged))
|> Repo.all() |> Repo.all()
end end
defp list_containers_search(query, nil), do: query @spec list_containers_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t()
defp list_containers_search(query, ""), do: query defp list_containers_staged(query, staged) when staged |> is_boolean(),
do: query |> where([c: c], c.staged == ^staged)
defp list_containers_search(query, search) do defp list_containers_staged(query, _nil), do: query
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_containers_search(query, search) when search in ["", nil],
do: query |> order_by([c: c], c.name)
defp list_containers_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
query query
@ -203,9 +217,9 @@ defmodule Cannery.Containers do
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Container.changeset()}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one( Repo.one(
from ag in AmmoGroup, from p in Pack,
where: ag.container_id == ^container.id, where: p.container_id == ^container.id,
select: count(ag.id) select: count(p.id)
) )
|> case do |> case do
0 -> 0 ->
@ -221,7 +235,7 @@ defmodule Cannery.Containers do
container container
|> Container.update_changeset(%{}) |> Container.update_changeset(%{})
|> Changeset.add_error(:ammo_groups, error) |> Changeset.add_error(:packs, error)
|> Changeset.apply_action(:delete) |> Changeset.apply_action(:delete)
end end
end end
@ -289,6 +303,9 @@ defmodule Cannery.Containers do
# Container Tags # Container Tags
@type list_tags_option :: {:search, String.t() | nil}
@type list_tags_options :: [list_tags_option()]
@doc """ @doc """
Returns the list of tags. Returns the list of tags.
@ -297,38 +314,42 @@ defmodule Cannery.Containers do
iex> list_tags(%User{id: 123}) iex> list_tags(%User{id: 123})
[%Tag{}, ...] [%Tag{}, ...]
iex> list_tags("cool", %User{id: 123}) iex> list_tags(%User{id: 123}, search: "cool")
[%Tag{name: "my cool tag"}, ...] [%Tag{name: "my cool tag"}, ...]
""" """
@spec list_tags(User.t()) :: [Tag.t()] @spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] @spec list_tags(User.t(), list_tags_options()) :: [Tag.t()]
def list_tags(search \\ nil, user) def list_tags(%User{id: user_id}, opts \\ []) do
from(t in Tag, as: :t, where: t.user_id == ^user_id)
|> list_tags_search(Keyword.get(opts, :search))
|> Repo.all()
end
def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", @spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) defp list_tags_search(query, search) when search in ["", nil],
do: query |> order_by([t: t], t.name)
def list_tags(search, %{id: user_id}) when search |> is_binary() do defp list_tags_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
Repo.all( query
from t in Tag, |> where(
where: t.user_id == ^user_id, [t: t],
where: fragment(
fragment( "? @@ websearch_to_tsquery('english', ?)",
"? @@ websearch_to_tsquery('english', ?)", t.search,
t.search, ^trimmed_search
^trimmed_search )
),
order_by: {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
}
) )
|> order_by([t: t], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
})
end end
@doc """ @doc """

View File

@ -3,45 +3,44 @@ defmodule Cannery.Containers.Container do
A container that holds ammunition and belongs to a user. A container that holds ammunition and belongs to a user.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset alias Cannery.{Containers.ContainerTag, Containers.Tag}
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
:id,
:name,
:desc, :desc,
:id,
:location, :location,
:type, :name,
:tags :staged,
:tags,
:type
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "containers" do schema "containers" do
field :name, :string
field :desc, :string field :desc, :string
field :location, :string field :location, :string
field :name, :string
field :staged, :boolean, default: false
field :type, :string field :type, :string
field :user_id, :binary_id field :user_id, :binary_id
many_to_many :tags, Tag, join_through: ContainerTag many_to_many :tags, Tag, join_through: ContainerTag
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t(), desc: String.t(),
id: id(),
location: String.t(), location: String.t(),
name: String.t(),
staged: boolean(),
type: String.t(), type: String.t(),
user_id: User.id(), user_id: User.id(),
tags: [Tag.t()] | nil, tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_container :: %__MODULE__{} @type new_container :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@ -52,19 +51,40 @@ defmodule Cannery.Containers.Container do
def create_changeset(container, %User{id: user_id}, attrs) do def create_changeset(container, %User{id: user_id}, attrs) do
container container
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [
:desc,
:location,
:name,
:staged,
:type
])
|> validate_length(:name, max: 255) |> validate_length(:name, max: 255)
|> validate_length(:type, max: 255) |> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id]) |> validate_required([
:name,
:staged,
:type,
:user_id
])
end end
@doc false @doc false
@spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset()
def update_changeset(container, attrs) do def update_changeset(container, attrs) do
container container
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [
:desc,
:location,
:name,
:staged,
:type
])
|> validate_length(:name, max: 255) |> validate_length(:name, max: 255)
|> validate_length(:type, max: 255) |> validate_length(:type, max: 255)
|> validate_required([:name, :type]) |> validate_required([
:name,
:staged,
:type
])
end end
end end

View File

@ -4,18 +4,14 @@ defmodule Cannery.Containers.ContainerTag do
Cannery.Containers.Tag. Cannery.Containers.Tag.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Containers.{Container, Tag} alias Cannery.Containers.{Container, Tag}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "container_tags" do schema "container_tags" do
belongs_to :container, Container belongs_to :container, Container
belongs_to :tag, Tag belongs_to :tag, Tag
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -24,8 +20,8 @@ defmodule Cannery.Containers.ContainerTag do
container_id: Container.id(), container_id: Container.id(),
tag: Tag.t(), tag: Tag.t(),
tag_id: Tag.id(), tag_id: Tag.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_container_tag :: %__MODULE__{} @type new_container_tag :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -4,10 +4,7 @@ defmodule Cannery.Containers.Tag do
text and bg colors. text and bg colors.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -16,8 +13,6 @@ defmodule Cannery.Containers.Tag do
:bg_color, :bg_color,
:text_color :text_color
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "tags" do schema "tags" do
field :name, :string field :name, :string
field :bg_color, :string field :bg_color, :string
@ -25,7 +20,7 @@ defmodule Cannery.Containers.Tag do
field :user_id, :binary_id field :user_id, :binary_id
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -34,8 +29,8 @@ defmodule Cannery.Containers.Tag do
bg_color: String.t(), bg_color: String.t(),
text_color: String.t(), text_color: String.t(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_tag() :: %__MODULE__{} @type new_tag() :: %__MODULE__{}
@type id() :: UUID.t() @type id() :: UUID.t()

View File

@ -3,14 +3,15 @@ defmodule Cannery.Email do
Emails that can be sent using Swoosh. Emails that can be sent using Swoosh.
You can find the base email templates at You can find the base email templates at
`lib/cannery_web/templates/layout/email.html.heex` for html emails and `lib/cannery_web/components/layouts/email_html.html.heex` for html emails and
`lib/cannery_web/templates/layout/email.txt.heex` for text emails. `lib/cannery_web/components/layouts/email_text.txt.eex` for text emails.
""" """
use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email} use Gettext, backend: CanneryWeb.Gettext
import CanneryWeb.Gettext import Swoosh.Email
import Phoenix.Template
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias CanneryWeb.EmailView alias CanneryWeb.{EmailHTML, Layouts}
@typedoc """ @typedoc """
Represents an HTML and text body email that can be sent Represents an HTML and text body email that can be sent
@ -28,21 +29,33 @@ defmodule Cannery.Email do
def generate_email("welcome", user, %{"url" => url}) do def generate_email("welcome", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Confirm your Cannery account")) |> base_email(dgettext("emails", "Confirm your Cannery account"))
|> render_body("confirm_email.html", %{user: user, url: url}) |> html_email(:confirm_email_html, %{user: user, url: url})
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) |> text_email(:confirm_email_text, %{user: user, url: url})
end end
def generate_email("reset_password", user, %{"url" => url}) do def generate_email("reset_password", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Reset your Cannery password")) |> base_email(dgettext("emails", "Reset your Cannery password"))
|> render_body("reset_password.html", %{user: user, url: url}) |> html_email(:reset_password_html, %{user: user, url: url})
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) |> text_email(:reset_password_text, %{user: user, url: url})
end end
def generate_email("update_email", user, %{"url" => url}) do def generate_email("update_email", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Update your Cannery email")) |> base_email(dgettext("emails", "Update your Cannery email"))
|> render_body("update_email.html", %{user: user, url: url}) |> html_email(:update_email_html, %{user: user, url: url})
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) |> text_email(:update_email_text, %{user: user, url: url})
end
defp html_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: heex)
email |> html_body(html)
end
defp text_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: heex)
email |> text_body(text)
end end
end end

View File

@ -14,17 +14,17 @@ defmodule Cannery.Logger do
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode() |> pretty_encode()
Logger.error(meta.reason, data: data) Logger.error("Oban exception: #{data}")
end end
def handle_event([:oban, :job, :start], measure, meta, _config) do def handle_event([:oban, :job, :start], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode() data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Started oban job", data: data) Logger.info("Started oban job: #{data}")
end end
def handle_event([:oban, :job, :stop], measure, meta, _config) do def handle_event([:oban, :job, :stop], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode() data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Finished oban job", data: data) Logger.info("Finished oban job: #{data}")
end end
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
@ -33,7 +33,7 @@ defmodule Cannery.Logger do
|> Map.put(:event, unhandled_event) |> Map.put(:event, unhandled_event)
|> pretty_encode() |> pretty_encode()
Logger.warning("Unhandled oban job event", data: data) Logger.warning("Unhandled oban job event: #{data}")
end end
def handle_event(unhandled_event, measure, meta, config) do def handle_event(unhandled_event, measure, meta, config) do
@ -45,7 +45,7 @@ defmodule Cannery.Logger do
config: config config: config
}) })
Logger.warning("Unhandled telemetry event", data: data) Logger.warning("Unhandled telemetry event: #{data}")
end end
defp get_oban_job_data(%{job: job}, measure) do defp get_oban_job_data(%{job: job}, measure) do

View File

@ -1,53 +1,62 @@
defmodule CanneryWeb do defmodule CanneryWeb do
@moduledoc """ @moduledoc """
The entrypoint for defining your web interface, such The entrypoint for defining your web interface, such
as controllers, views, channels and so on. as controllers, components, channels, and so on.
This can be used in your application as: This can be used in your application as:
use CanneryWeb, :controller use CanneryWeb, :controller
use CanneryWeb, :view use CanneryWeb, :html
The definitions below will be executed for every view, The definitions below will be executed for every controller,
controller, etc, so keep them short and clean, focused component, etc, so keep them short and clean, focused
on imports, uses and aliases. on imports, uses and aliases.
Do NOT define functions inside the quoted expressions Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules below. Instead, define additional modules and import
and import those modules here. those modules here.
""" """
def controller do def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
quote do
use Phoenix.Controller, namespace: CanneryWeb
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn import Plug.Conn
import CanneryWeb.Gettext import Phoenix.Controller
alias CanneryWeb.Router.Helpers, as: Routes import Phoenix.LiveView.Router
end end
end end
def view do def channel do
quote do quote do
use Phoenix.View, use Phoenix.Channel
root: "lib/cannery_web/templates", end
namespace: CanneryWeb end
# Import convenience functions from controllers def controller do
import Phoenix.Controller, quote do
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts]
# Include shared imports and aliases for views use Gettext, backend: CanneryWeb.Gettext
unquote(view_helpers())
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
unquote(verified_routes())
end end
end end
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live} use Phoenix.LiveView,
layout: {CanneryWeb.Layouts, :app}
on_mount CanneryWeb.InitAssigns unquote(html_helpers())
unquote(view_helpers())
end end
end end
@ -55,49 +64,45 @@ defmodule CanneryWeb do
quote do quote do
use Phoenix.LiveComponent use Phoenix.LiveComponent
unquote(view_helpers()) unquote(html_helpers())
end end
end end
def component do def html do
quote do quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component use Phoenix.Component
unquote(view_helpers()) # Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end end
end end
def router do defp html_helpers do
quote do quote do
use Phoenix.Router use PhoenixHTMLHelpers
use Gettext, backend: CanneryWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
import Phoenix.{Controller, LiveView.Router} # Shortcut for generating JS commands
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse alias Phoenix.LiveView.JS
import Plug.Conn
# Routes generation with the ~p sigil
unquote(verified_routes())
end end
end end
def channel do def verified_routes do
quote do quote do
use Phoenix.Channel use Phoenix.VerifiedRoutes,
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse endpoint: CanneryWeb.Endpoint,
import CanneryWeb.Gettext router: CanneryWeb.Router,
end statics: CanneryWeb.static_paths()
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
# Import basic rendering functionality (render, render_layout, etc)
import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers}
import Phoenix.{Component, View}
alias CanneryWeb.Endpoint
alias CanneryWeb.Router.Helpers, as: Routes
end end
end end

View File

@ -1,62 +0,0 @@
<div>
<h2 class="mb-8 text-center title text-xl text-primary-600">
<%= gettext("Record shots") %>
</h2>
<.form
:let={f}
for={@changeset}
id="shot-group-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div
:if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :ammo_left,
min: 0,
max: @ammo_group.count - 1,
placeholder: gettext("Rounds left"),
class: "input input-primary"
) %>
<button
type="button"
class="mx-2 my-1 text-sm btn btn-primary"
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")}
>
<%= gettext("Used up!") %>
</button>
<%= error_tag(f, :ammo_left, "col-span-3") %>
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes,
id: "add-shot-group-form-notes",
class: "input input-primary col-span-2",
maxlength: 255,
placeholder: gettext("Really great weather"),
phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %>
<%= error_tag(f, :notes, "col-span-3") %>
<%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %>
<%= date_input(f, :date,
class: "input input-primary col-span-2",
phx_update: "ignore",
value: Date.utc_today()
) %>
<%= error_tag(f, :notes, "col-span-3") %>
<%= submit(dgettext("actions", "Save"),
class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "Saving...")
) %>
</.form>
</div>

View File

@ -1,10 +1,10 @@
defmodule CanneryWeb.Components.AddShotGroupComponent do defmodule CanneryWeb.Components.AddShotRecordComponent do
@moduledoc """ @moduledoc """
Livecomponent that can create a ShotGroup Livecomponent that can create a ShotRecord
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo.Pack}
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.{JS, Socket} alias Phoenix.LiveView.{JS, Socket}
@ -12,15 +12,15 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
@spec update( @spec update(
%{ %{
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:ammo_group) => AmmoGroup.t(), required(:pack) => Pack.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do def update(%{pack: pack, current_user: current_user} = assigns, socket) do
changeset = changeset =
%ShotGroup{date: Date.utc_today()} %ShotRecord{date: Date.utc_today()}
|> ShotGroup.create_changeset(current_user, ammo_group, %{}) |> ShotRecord.create_changeset(current_user, pack, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
end end
@ -28,12 +28,14 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
@impl true @impl true
def handle_event( def handle_event(
"validate", "validate",
%{"shot_group" => shot_group_params}, %{"shot_record" => shot_record_params},
%{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket %{assigns: %{pack: pack, current_user: current_user}} = socket
) do ) do
params = shot_group_params |> process_params(ammo_group) params = shot_record_params |> process_params(pack)
changeset = %ShotGroup{} |> ShotGroup.create_changeset(current_user, ammo_group, params) changeset =
%ShotRecord{}
|> ShotRecord.create_changeset(current_user, pack, params)
changeset = changeset =
case changeset |> Changeset.apply_action(:validate) do case changeset |> Changeset.apply_action(:validate) do
@ -46,17 +48,17 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
def handle_event( def handle_event(
"save", "save",
%{"shot_group" => shot_group_params}, %{"shot_record" => shot_record_params},
%{ %{
assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to} assigns: %{pack: pack, current_user: current_user, return_to: return_to}
} = socket } = socket
) do ) do
socket = socket =
shot_group_params shot_record_params
|> process_params(ammo_group) |> process_params(pack)
|> ActivityLog.create_shot_group(current_user, ammo_group) |> ActivityLog.create_shot_record(current_user, pack)
|> case do |> case do
{:ok, _shot_group} -> {:ok, _shot_record} ->
prompt = dgettext("prompts", "Shots recorded successfully") prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -68,8 +70,8 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
end end
# calculate count from shots left # calculate count from shots left
defp process_params(params, %AmmoGroup{count: count}) do defp process_params(params, %Pack{count: count}) do
shot_group_count = shot_record_count =
if params |> Map.get("ammo_left", "") == "" do if params |> Map.get("ammo_left", "") == "" do
nil nil
else else
@ -77,6 +79,6 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
count - new_count count - new_count
end end
params |> Map.put("count", shot_group_count) params |> Map.put("count", shot_record_count)
end end
end end

View File

@ -0,0 +1,62 @@
<div>
<h2 class="mb-8 text-center title text-xl text-primary-600">
{gettext("Record shots")}
</h2>
<.form
:let={f}
for={@changeset}
id="shot-record-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div
:if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center"
>
{changeset_errors(@changeset)}
</div>
{label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600")}
{number_input(f, :ammo_left,
min: 0,
max: @pack.count - 1,
placeholder: gettext("Rounds left"),
class: "input input-primary"
)}
<button
type="button"
class="mx-2 my-1 text-sm btn btn-primary"
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")}
>
{gettext("Used up!")}
</button>
{error_tag(f, :ammo_left, "col-span-3")}
{label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600")}
{textarea(f, :notes,
class: "input input-primary col-span-2",
id: "add-shot-record-form-notes",
maxlength: 255,
phx_debounce: 300,
phx_update: "ignore",
placeholder: gettext("Really great weather")
)}
{error_tag(f, :notes, "col-span-3")}
{label(f, :date, gettext("Date"), class: "title text-lg text-primary-600")}
{date_input(f, :date,
class: "input input-primary col-span-2",
phx_update: "ignore",
value: Date.utc_today()
)}
{error_tag(f, :notes, "col-span-3")}
{submit(dgettext("actions", "Save"),
class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "Saving...")
)}
</.form>
</div>

View File

@ -4,6 +4,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Containers.Container} alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -13,6 +14,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:containers) => [Container.t()], optional(:containers) => [Container.t()],
optional(:range) => Rendered.t(),
optional(:tag_actions) => Rendered.t(), optional(:tag_actions) => Rendered.t(),
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
optional(any()) => any() optional(any()) => any()
@ -23,6 +25,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:range, fn -> [] end)
|> assign_new(:tag_actions, fn -> [] end) |> assign_new(:tag_actions, fn -> [] end)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_containers() |> display_containers()
@ -35,6 +38,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns: %{ assigns: %{
containers: containers, containers: containers,
current_user: current_user, current_user: current_user,
range: range,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions actions: actions
} }
@ -62,17 +66,34 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end) end)
|> Enum.concat([ |> Enum.concat([
%{label: gettext("Packs"), key: :packs, type: :integer}, %{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :integer}, %{label: gettext("Rounds"), key: :rounds, type: :integer}
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
]) ])
|> Enum.concat(
[
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
]
|> TableComponent.maybe_compose_columns(
%{label: gettext("Range"), key: :range},
range != []
)
)
extra_data = %{ extra_data = %{
current_user: current_user, current_user: current_user,
range: range,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions, actions: actions,
pack_count: Ammo.get_ammo_groups_count_for_containers(containers, current_user), pack_count:
round_count: Ammo.get_round_count_for_containers(containers, current_user) Ammo.get_grouped_packs_count(current_user,
containers: containers,
group_by: :container_id
),
round_count:
Ammo.get_grouped_round_count(current_user,
containers: containers,
group_by: :container_id
)
} }
rows = rows =
@ -109,14 +130,12 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end end
@spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any() @spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any()
defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do defp get_value_for_key(:name, %{name: container_name} = assigns, _extra_data) do
assigns = %{id: id, container_name: container_name}
{container_name, {container_name,
~H""" ~H"""
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link"> <.link navigate={~p"/container/#{@id}"} class="link">
<%= @container_name %> {@name}
</.link> </.link>
</div> </div>
"""} """}
@ -130,6 +149,15 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
round_count |> Map.get(container_id, 0) round_count |> Map.get(container_id, 0)
end end
defp get_value_for_key(:range, %{staged: staged} = container, %{range: range}) do
assigns = %{range: range, container: container}
{staged,
~H"""
{render_slot(@range, @container)}
"""}
end
defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do
assigns = %{tag_actions: tag_actions, container: container} assigns = %{tag_actions: tag_actions, container: container}
@ -144,7 +172,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= render_slot(@tag_actions, @container) %> {render_slot(@tag_actions, @container)}
</div> </div>
"""} """}
end end
@ -153,7 +181,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns = %{actions: actions, container: container} assigns = %{actions: actions, container: container}
~H""" ~H"""
<%= render_slot(@actions, @container) %> {render_slot(@actions, @container)}
""" """
end end

View File

@ -3,11 +3,12 @@ defmodule CanneryWeb.CoreComponents do
Provides core UI components. Provides core UI components.
""" """
use Phoenix.Component use Phoenix.Component
import CanneryWeb.{Gettext, ViewHelpers} use CanneryWeb, :verified_routes
use Gettext, backend: CanneryWeb.Gettext
import CanneryWeb.HTMLHelpers
alias Cannery.{Accounts, Accounts.Invite, Accounts.User} alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.AmmoGroup} alias Cannery.{Ammo, Ammo.Pack}
alias Cannery.{Containers.Container, Containers.Tag} alias Cannery.{Containers.Container, Containers.Tag}
alias CanneryWeb.{Endpoint, HomeLive}
alias CanneryWeb.Router.Helpers, as: Routes alias CanneryWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.{JS, Rendered} alias Phoenix.LiveView.{JS, Rendered}
@ -29,13 +30,13 @@ defmodule CanneryWeb.CoreComponents do
## Examples ## Examples
<.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> <.modal return_to={~p"/\#{<%= schema.plural %>}"}>
<.live_component <.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new} id={@<%= schema.singular %>.id || :new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} return_to={~p"/\#{<%= schema.singular %>}"}
<%= schema.singular %>: @<%= schema.singular %> <%= schema.singular %>: @<%= schema.singular %>
/> />
</.modal> </.modal>
@ -86,7 +87,7 @@ defmodule CanneryWeb.CoreComponents do
def simple_tag_card(assigns) def simple_tag_card(assigns)
attr :ammo_group, AmmoGroup, required: true attr :pack, Pack, required: true
attr :current_user, User, required: true attr :current_user, User, required: true
attr :original_count, :integer, default: nil attr :original_count, :integer, default: nil
attr :cpr, :integer, default: nil attr :cpr, :integer, default: nil
@ -94,7 +95,7 @@ defmodule CanneryWeb.CoreComponents do
attr :container, Container, default: nil attr :container, Container, default: nil
slot(:inner_block) slot(:inner_block)
def ammo_group_card(assigns) def pack_card(assigns)
@spec display_currency(float()) :: String.t() @spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
@ -135,14 +136,26 @@ defmodule CanneryWeb.CoreComponents do
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """ @doc """
Phoenix.Component for a <time> element that renders the naivedatetime in the Phoenix.Component for a <time> element that renders the DateTime in the
user's local timezone user's local timezone
""" """
def datetime(assigns) def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() attr :name, :string, required: true
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) attr :start_date, :string,
default: Date.utc_today() |> Date.shift(year: -1) |> Date.to_iso8601()
attr :end_date, :string, default: Date.utc_today() |> Date.to_iso8601()
@doc """
Phoenix.Component for an element that generates date fields for a range
"""
def date_range(assigns)
@spec cast_datetime(DateTime.t() | nil) :: String.t()
defp cast_datetime(%DateTime{} = datetime) do
datetime |> DateTime.to_iso8601(:extended)
end end
defp cast_datetime(_datetime), do: "" defp cast_datetime(_datetime), do: ""

View File

@ -1,65 +0,0 @@
<div
id={"ammo_group-#{@ammo_group.id}"}
class="mx-4 my-2 px-8 py-4
flex flex-col justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
<%= @ammo_group.ammo_type.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
<%= gettext("Count:") %>
<%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %>
</span>
<span
:if={@original_count && @original_count != @ammo_group.count}
class="rounded-lg title text-lg"
>
<%= gettext("Original Count:") %>
<%= @original_count %>
</span>
<span :if={@ammo_group.notes} class="rounded-lg title text-lg">
<%= gettext("Notes:") %>
<%= @ammo_group.notes %>
</span>
<span :if={@ammo_group.purchased_on} class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %>
<.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} />
</span>
<span :if={@last_used_date} class="rounded-lg title text-lg">
<%= gettext("Last used on:") %>
<.date id={"#{@ammo_group.id}-last-used-on"} date={@last_used_date} />
</span>
<span :if={@ammo_group.price_paid} class="rounded-lg title text-lg">
<%= gettext("Price paid:") %>
<%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %>
</span>
<span :if={@cpr} class="rounded-lg title text-lg">
<%= gettext("CPR:") %>
<%= gettext("$%{amount}", amount: display_currency(@cpr)) %>
</span>
<span :if={@container} class="rounded-lg title text-lg">
<%= gettext("Container:") %>
<.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link">
<%= @container.name %>
</.link>
</span>
</div>
<div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
</div>

View File

@ -2,40 +2,40 @@
id={"container-#{@container.id}"} id={"container-#{@container.id}"}
class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4
flex flex-col justify-around items-center space-y-4 flex flex-col justify-around items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out" transition-all duration-300 ease-in-out"
> >
<.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> <.link navigate={~p"/container/#{@container}"} class="link">
<h1 class="px-4 py-2 rounded-lg title text-xl"> <h1 class="px-4 py-2 rounded-lg title text-xl">
<%= @container.name %> {@container.name}
</h1> </h1>
</.link> </.link>
<div class="flex flex-col justify-center items-center space-y-2"> <div class="flex flex-col justify-center items-center space-y-2">
<span :if={@container.desc} class="rounded-lg title text-lg"> <span :if={@container.desc} class="rounded-lg title text-lg">
<%= gettext("Description:") %> {gettext("Description:")}
<%= @container.desc %> {@container.desc}
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Type:") %> {gettext("Type:")}
<%= @container.type %> {@container.type}
</span> </span>
<span :if={@container.location} class="rounded-lg title text-lg"> <span :if={@container.location} class="rounded-lg title text-lg">
<%= gettext("Location:") %> {gettext("Location:")}
<%= @container.location %> {@container.location}
</span> </span>
<%= if @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) != 0 do %> <%= if Ammo.get_packs_count(@current_user, container_id: @container.id) != 0 do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Packs:") %> {gettext("Packs:")}
<%= @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) %> {Ammo.get_packs_count(@current_user, container_id: @container.id)}
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %> {gettext("Rounds:")}
<%= @container |> Ammo.get_round_count_for_container!(@current_user) %> {Ammo.get_round_count(@current_user, container_id: @container.id)}
</span> </span>
<% end %> <% end %>
@ -45,7 +45,7 @@
> >
<.simple_tag_card :for={tag <- @container.tags} tag={tag} /> <.simple_tag_card :for={tag <- @container.tags} tag={tag} />
<%= if @tag_actions, do: render_slot(@tag_actions) %> {if @tag_actions, do: render_slot(@tag_actions)}
</div> </div>
</div> </div>
@ -53,6 +53,6 @@
:if={assigns |> Map.has_key?(:inner_block)} :if={assigns |> Map.has_key?(:inner_block)}
class="flex space-x-4 justify-center items-center" class="flex space-x-4 justify-center items-center"
> >
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>

View File

@ -1,3 +1,3 @@
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date">
<%= Date.to_iso8601(@date, :extended) %> {Date.to_iso8601(@date, :extended)}
</time> </time>

View File

@ -0,0 +1,15 @@
<div class="flex items-center mx-4 my-2 space-x-1">
<input
class="w-40 text-center input input-primary"
name={"#{@name}_start"}
type="date"
value={@start_date}
/>
<span>—</span>
<input
class="w-40 text-center input input-primary"
name={"#{@name}_end"}
type="date"
value={@end_date}
/>
</div>

View File

@ -1,3 +1,3 @@
<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
<%= cast_datetime(@datetime) %> {cast_datetime(@datetime)}
</time> </time>

View File

@ -1,46 +1,46 @@
<div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"> transition-all duration-300 ease-in-out">
<h1 class="title text-xl"> <h1 class="title text-xl">
<%= @invite.name %> {@invite.name}
</h1> </h1>
<%= if @invite.disabled_at |> is_nil() do %> <%= if @invite.disabled_at |> is_nil() do %>
<h2 class="title text-md"> <h2 class="title text-md">
<%= if @invite.uses_left do %> <%= if @invite.uses_left do %>
<%= gettext( {gettext(
"Uses Left: %{uses_left_count}", "Uses Left: %{uses_left_count}",
uses_left_count: @invite.uses_left uses_left_count: @invite.uses_left
) %> )}
<% else %> <% else %>
<%= gettext("Uses Left: Unlimited") %> {gettext("Uses Left: Unlimited")}
<% end %> <% end %>
</h2> </h2>
<% else %> <% else %>
<h2 class="title text-md"> <h2 class="title text-md">
<%= gettext("Invite Disabled") %> {gettext("Invite Disabled")}
</h2> </h2>
<% end %> <% end %>
<.qr_code <.qr_code
content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} content={url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")}
filename={@invite.name} filename={@invite.name}
/> />
<h2 :if={@use_count && @use_count != 0} class="title text-md"> <h2 :if={@use_count && @use_count != 0} class="title text-md">
<%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> {gettext("Uses: %{uses_count}", uses_count: @use_count)}
</h2> </h2>
<div class="flex flex-row flex-wrap justify-center items-center"> <div class="flex flex-row flex-wrap justify-center items-center">
<code <code
id={"code-#{@invite.id}"} id={"code-#{@invite.id}"}
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-zinc-100 bg-primary-800"
phx-no-format phx-no-format
><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> ><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code>
<%= if @code_actions, do: render_slot(@code_actions) %> {if @code_actions, do: render_slot(@code_actions)}
</div> </div>
<div :if={@inner_block} class="flex space-x-4 justify-center items-center"> <div :if={@inner_block} class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>

View File

@ -29,7 +29,7 @@
patch={@return_to} patch={@return_to}
id="close" id="close"
class="absolute top-8 right-10 class="absolute top-8 right-10
text-gray-500 hover:text-gray-800 text-zinc-500 hover:text-zinc-800
transition-all duration-500 ease-in-out" transition-all duration-500 ease-in-out"
phx-remove={hide_modal()} phx-remove={hide_modal()}
aria-label={gettext("Close modal")} aria-label={gettext("Close modal")}
@ -38,7 +38,7 @@
</.link> </.link>
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,67 @@
<div
id={"pack-#{@pack.id}"}
class="mx-4 my-2 px-8 py-4
flex flex-col justify-center items-center
border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
{@pack.type.name}
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
{gettext("Count:")}
{if @pack.count == 0, do: gettext("Empty"), else: @pack.count}
</span>
<span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg">
{gettext("Original Count:")}
{@original_count}
</span>
<span :if={@pack.notes} class="rounded-lg title text-lg">
{gettext("Notes:")}
{@pack.notes}
</span>
<span :if={@pack.purchased_on} class="rounded-lg title text-lg">
{gettext("Purchased on:")}
<.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} />
</span>
<span :if={@last_used_date} class="rounded-lg title text-lg">
{gettext("Last used on:")}
<.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} />
</span>
<span :if={@pack.price_paid} class="rounded-lg title text-lg">
{gettext("Price paid:")}
{gettext("$%{amount}", amount: display_currency(@pack.price_paid))}
</span>
<span :if={@cpr} class="rounded-lg title text-lg">
{gettext("CPR:")}
{gettext("$%{amount}", amount: display_currency(@cpr))}
</span>
<span :if={@pack.lot_number} class="rounded-lg title text-lg">
{gettext("Lot number:")}
{@pack.lot_number}
</span>
<span :if={@container} class="rounded-lg title text-lg">
{gettext("Container:")}
<.link navigate={~p"/container/#{@container}"} class="link">
{@container.name}
</.link>
</span>
</div>
<div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center">
{render_slot(@inner_block)}
</div>
</div>

View File

@ -2,5 +2,5 @@
class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl" class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"} style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
> >
<%= @tag.name %> {@tag.name}
</h1> </h1>

View File

@ -1,9 +1,9 @@
<div <div
id={"tag-#{@tag.id}"} id={"tag-#{@tag.id}"}
class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out" transition-all duration-300 ease-in-out"
> >
<.simple_tag_card tag={@tag} /> <.simple_tag_card tag={@tag} />
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>

View File

@ -1,4 +1,4 @@
<label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> <label for={@id || @action} class="inline-flex relative items-center cursor-pointer">
<input <input
id={@id || @action} id={@id || @action}
type="checkbox" type="checkbox"
@ -12,19 +12,17 @@
else: %{"phx-click": @action, "phx-value-value": @value} else: %{"phx-click": @action, "phx-value-value": @value}
} }
/> />
<div class="w-11 h-6 bg-gray-300 rounded-full peer <div class="w-11 h-6 bg-zinc-300 rounded-full peer
peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 peer-checked:bg-zinc-600 peer-checked:after:translate-x-full peer-checked:after:border-white
peer-checked:bg-gray-600 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300
after:border after:rounded-full after:h-5 after:w-5 after:border after:rounded-full after:h-5 after:w-5
after:transition-all after:duration-250 after:ease-in-out after:transition-all after:duration-250 after:ease-in-out
transition-colors duration-250 ease-in-out"> transition-colors duration-250 ease-in-out">
</div> </div>
<span <span
id={"#{@id || @action}-label"} id={"#{@id || @action}-label"}
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" class="ml-3 text-sm font-medium whitespace-nowrap text-zinc-900 dark:text-zinc-300"
> >
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</span> </span>
</label> </label>

View File

@ -1,12 +1,9 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500"> <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500">
<div class="flex flex-col sm:flex-row justify-between items-center"> <div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link <.link navigate={~p"/"} class="inline mx-2 my-1 leading-5 text-xl text-white">
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img <img
src={Routes.static_path(Endpoint, "/images/cannery.svg")} src={~p"/images/cannery.svg"}
alt={gettext("Cannery logo")} alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1" class="inline-block h-8 mx-1"
/> />
@ -17,7 +14,7 @@
<span class="mx-2 my-1"> <span class="mx-2 my-1">
| |
</span> </span>
<%= @title_content %> {@title_content}
<% end %> <% end %>
</div> </div>
@ -27,64 +24,43 @@
text-lg text-white text-ellipsis"> text-lg text-white text-ellipsis">
<%= if @current_user do %> <%= if @current_user do %>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link <.link navigate={~p"/tags"} class="text-white hover:underline">
navigate={Routes.tag_index_path(Endpoint, :index)} {gettext("Tags")}
class="text-white hover:underline" </.link>
> </li>
<%= gettext("Tags") %> <li class="mx-2 my-1">
<.link navigate={~p"/containers"} class="text-white hover:underline">
{gettext("Containers")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/catalog"} class="text-white hover:underline">
{gettext("Catalog")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/ammo"} class="text-white hover:underline">
{gettext("Ammo")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/range"} class="text-white hover:underline">
{gettext("Range")}
</.link>
</li>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-white hover:underline">
{gettext("Invites")}
</.link>
</li>
<li class="mx-2 my-1">
<.link href={~p"/users/settings"} class="text-white hover:underline truncate">
{@current_user.email}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link <.link
navigate={Routes.container_index_path(Endpoint, :index)} href={~p"/users/log_out"}
class="text-white hover:underline"
>
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_type_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_group_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Range") %>
</.link>
</li>
<li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Invites") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_settings_path(Endpoint, :edit)}
class="text-white hover:underline truncate"
>
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
method="delete" method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")} data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
aria-label={gettext("Log out")} aria-label={gettext("Log out")}
@ -94,13 +70,13 @@
</li> </li>
<li <li
:if={ :if={
@current_user |> Accounts.is_already_admin?() and @current_user |> Accounts.already_admin?() and
function_exported?(Routes, :live_dashboard_path, 2) function_exported?(Routes, :live_dashboard_path, 2)
} }
class="mx-2 my-1" class="mx-2 my-1"
> >
<.link <.link
navigate={Routes.live_dashboard_path(Endpoint, :home)} navigate={~p"/dashboard"}
class="text-white hover:underline" class="text-white hover:underline"
aria-label={gettext("Live Dashboard")} aria-label={gettext("Live Dashboard")}
> >
@ -109,19 +85,13 @@
</li> </li>
<% else %> <% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1"> <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link <.link href={~p"/users/register"} class="text-white hover:underline truncate">
href={Routes.user_registration_path(Endpoint, :new)} {dgettext("actions", "Register")}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link <.link href={~p"/users/log_in"} class="text-white hover:underline truncate">
href={Routes.user_session_path(Endpoint, :new)} {dgettext("actions", "Log in")}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %>
</.link> </.link>
</li> </li>
<% end %> <% end %>

View File

@ -1,36 +1,36 @@
<div <div
id={"user-#{@user.id}"} id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out" transition-all duration-300 ease-in-out"
> >
<h1 class="px-4 py-2 rounded-lg title text-xl break-all"> <h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %> {@user.email}
</h1> </h1>
<h3 class="px-4 py-2 rounded-lg title text-lg"> <h3 class="px-4 py-2 rounded-lg title text-lg">
<p> <p>
<%= if @user.confirmed_at do %> <%= if @user.confirmed_at do %>
<%= gettext( {gettext(
"User was confirmed at%{confirmed_datetime}", "User was confirmed at%{confirmed_datetime}",
confirmed_datetime: "" confirmed_datetime: ""
) %> )}
<.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} />
<% else %> <% else %>
<%= gettext("Email unconfirmed") %> {gettext("Email unconfirmed")}
<% end %> <% end %>
</p> </p>
<p> <p>
<%= gettext( {gettext(
"User registered on%{registered_datetime}", "User registered on%{registered_datetime}",
registered_datetime: "" registered_datetime: ""
) %> )}
<.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} />
</p> </p>
</h3> </h3>
<div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>

View File

@ -0,0 +1,10 @@
defmodule CanneryWeb.EmailHTML do
@moduledoc """
Renders email templates
"""
use CanneryWeb, :html
embed_templates "email_html/*.html", suffix: "_html"
embed_templates "email_html/*.txt", suffix: "_text"
end

View File

@ -0,0 +1,23 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.75em; font-size: 1.5em;">
{dgettext("emails", "Hi %{email},", email: @user.email)}
</span>
<br />
<span style="margin-bottom: 1em; font-size: 1.25em;">
{dgettext("emails", "Welcome to Cannery")}
</span>
<br />
{dgettext("emails", "You can confirm your account by visiting the URL below:")}
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br />
{dgettext("emails", "If you didn't create an account at Cannery, please ignore this.")}
</div>

View File

@ -9,4 +9,4 @@
<%= dgettext("emails", <%= dgettext("emails",
"If you didn't create an account at %{url}, please ignore this.", "If you didn't create an account at %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %> url: ~p"/") %>

View File

@ -0,0 +1,17 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;">
{dgettext("emails", "Hi %{email},", email: @user.email)}
</span>
<br />
{dgettext("emails", "You can reset your password by visiting the URL below:")}
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br />
{dgettext("emails", "If you didn't request this change from Cannery, please ignore this.")}
</div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails", <%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.", "If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %> url: ~p"/") %>

View File

@ -1,20 +1,20 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;"> <span style="margin-bottom: 0.5em; font-size: 1.5em;">
<%= dgettext("emails", "Hi %{email},", email: @user.email) %> {dgettext("emails", "Hi %{email},", email: @user.email)}
</span> </span>
<br /> <br />
<%= dgettext("emails", "You can change your email by visiting the URL below:") %> {dgettext("emails", "You can change your email by visiting the URL below:")}
<br /> <br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br /> <br />
<%= dgettext( {dgettext(
"emails", "emails",
"If you didn't request this change from Cannery, please ignore this." "If you didn't request this change from Cannery, please ignore this."
) %> )}
</div> </div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails", <%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.", "If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %> url: ~p"/") %>

View File

@ -0,0 +1,17 @@
defmodule CanneryWeb.Layouts do
@moduledoc """
The root layouts for the entire application
"""
use CanneryWeb, :html
embed_templates "layouts/*"
def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do
gettext("Cannery | %{title}", title: title)
end
def get_title(_conn) do
gettext("Cannery")
end
end

View File

@ -10,7 +10,7 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="info" phx-value-key="info"
> >
<%= live_flash(@flash, "info") %> {Phoenix.Flash.get(@flash, :info)}
</p> </p>
<p <p
@ -20,13 +20,13 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="error" phx-value-key="error"
> >
<%= live_flash(@flash, "error") %> {Phoenix.Flash.get(@flash, :error)}
</p> </p>
</div> </div>
</header> </header>
<div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch">
<%= @inner_content %> {@inner_content}
</div> </div>
</main> </main>
@ -40,6 +40,6 @@
<i class="fas fa-fade text-md fa-satellite-dish"></i> <i class="fas fa-fade text-md fa-satellite-dish"></i>
<h1 class="title text-md title-primary-500"> <h1 class="title text-md title-primary-500">
<%= gettext("Reconnecting...") %> {gettext("Reconnecting...")}
</h1> </h1>
</div> </div>

View File

@ -1,19 +1,19 @@
<html> <html>
<head> <head>
<title> <title>
<%= @email.subject %> {@email.subject}
</title> </title>
</head> </head>
<body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;">
<%= @inner_content %> {@inner_content}
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" />
<a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}> <a style="color: rgb(31, 31, 31);" href={~p"/"}>
<%= dgettext( {dgettext(
"emails", "emails",
"This email was sent from Cannery, the self-hosted firearm tracker website." "This email was sent from Cannery, the self-hosted firearm tracker website."
) %> )}
</a> </a>
</body> </body>
</html> </html>

View File

@ -8,4 +8,4 @@
<%= dgettext("emails", <%= dgettext("emails",
"This email was sent from Cannery at %{url}, the self-hosted firearm tracker website.", "This email was sent from Cannery at %{url}, the self-hosted firearm tracker website.",
url: Routes.live_url(Endpoint, HomeLive)) %> url: ~p"/") %>

View File

@ -0,0 +1 @@
{@inner_block}

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" class="p-0 m-0 w-full h-full bg-white [scrollbar-gutter:stable]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} />
<.live_title suffix={" | #{gettext("Cannery")}"}>
{assigns[:page_title] || gettext("Cannery")}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/style.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="p-0 m-0 w-full h-full subpixel-antialiased">
{@inner_content}
</body>
</html>

View File

@ -1,11 +1,10 @@
defmodule CanneryWeb.Components.MoveAmmoGroupComponent do defmodule CanneryWeb.Components.MovePackComponent do
@moduledoc """ @moduledoc """
Livecomponent that can move an ammo group to another container Livecomponent that can move a pack to another container
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container} alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@ -13,17 +12,16 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@spec update( @spec update(
%{ %{
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:ammo_group) => AmmoGroup.t(), required(:pack) => Pack.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update( def update(
%{ammo_group: %{container_id: container_id} = ammo_group, current_user: current_user} = %{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns,
assigns,
socket socket
) do ) do
changeset = ammo_group |> AmmoGroup.update_changeset(%{}, current_user) changeset = pack |> Pack.update_changeset(%{}, current_user)
containers = containers =
Containers.list_containers(current_user) Containers.list_containers(current_user)
@ -41,16 +39,15 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
def handle_event( def handle_event(
"move", "move",
%{"container_id" => container_id}, %{"container_id" => container_id},
%{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = %{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket
socket
) do ) do
%{name: container_name} = Containers.get_container!(container_id, current_user) %{name: container_name} = Containers.get_container!(container_id, current_user)
socket = socket =
ammo_group pack
|> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user) |> Ammo.update_pack(%{"container_id" => container_id}, current_user)
|> case do |> case do
{:ok, _ammo_group} -> {:ok, _pack} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -77,22 +74,22 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
~H""" ~H"""
<div class="w-full flex flex-col space-y-8 justify-center items-center"> <div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600"> <h2 class="mb-8 text-center title text-xl text-primary-600">
<%= dgettext("actions", "Move ammo") %> {dgettext("actions", "Move ammo")}
</h2> </h2>
<%= if @containers |> Enum.empty?() do %> <%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= gettext("No other containers") %> {gettext("No other containers")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
<.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> <.link navigate={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %> {dgettext("actions", "Add another container!")}
</.link> </.link>
<% else %> <% else %>
<.live_component <.live_component
module={CanneryWeb.Components.TableComponent} module={CanneryWeb.Components.TableComponent}
id="move_ammo_group_table" id="move-pack-table"
columns={@columns} columns={@columns}
rows={@rows} rows={@rows}
/> />
@ -123,7 +120,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
phx-target={@myself} phx-target={@myself}
phx-value-container_id={@container.id} phx-value-container_id={@container.id}
> >
<%= dgettext("actions", "Select") %> {dgettext("actions", "Select")}
</button> </button>
</div> </div>
""" """

View File

@ -1,9 +1,9 @@
defmodule CanneryWeb.Components.AmmoGroupTableComponent do defmodule CanneryWeb.Components.PackTableComponent do
@moduledoc """ @moduledoc """
A component that displays a list of ammo groups A component that displays a list of packs
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo.AmmoGroup, ComparableDate} alias Cannery.{Accounts.User, Ammo.Pack, ComparableDate}
alias Cannery.{ActivityLog, Ammo, Containers} alias Cannery.{ActivityLog, Ammo, Containers}
alias CanneryWeb.Components.TableComponent alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
@ -14,9 +14,9 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
%{ %{
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:ammo_groups) => [AmmoGroup.t()], required(:packs) => [Pack.t()],
required(:show_used) => boolean(), required(:show_used) => boolean(),
optional(:ammo_type) => Rendered.t(), optional(:type) => Rendered.t(),
optional(:range) => Rendered.t(), optional(:range) => Rendered.t(),
optional(:container) => Rendered.t(), optional(:container) => Rendered.t(),
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
@ -25,28 +25,27 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update( def update(
%{id: _id, ammo_groups: _ammo_group, current_user: _current_user, show_used: _show_used} = %{id: _id, packs: _pack, current_user: _current_user, show_used: _show_used} = assigns,
assigns,
socket socket
) do ) do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:ammo_type, fn -> [] end) |> assign_new(:type, fn -> [] end)
|> assign_new(:range, fn -> [] end) |> assign_new(:range, fn -> [] end)
|> assign_new(:container, fn -> [] end) |> assign_new(:container, fn -> [] end)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_ammo_groups() |> display_packs()
{:ok, socket} {:ok, socket}
end end
defp display_ammo_groups( defp display_packs(
%{ %{
assigns: %{ assigns: %{
ammo_groups: ammo_groups, packs: packs,
current_user: current_user, current_user: current_user,
ammo_type: ammo_type, type: type,
range: range, range: range,
container: container, container: container,
actions: actions, actions: actions,
@ -54,6 +53,9 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
} }
} = socket } = socket
) do ) do
lot_number_used = packs |> Enum.any?(fn %{lot_number: lot_number} -> !!lot_number end)
price_paid_used = packs |> Enum.any?(fn %{price_paid: price_paid} -> !!price_paid end)
columns = columns =
[] []
|> TableComponent.maybe_compose_columns( |> TableComponent.maybe_compose_columns(
@ -78,8 +80,18 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
%{label: gettext("Range"), key: :range}, %{label: gettext("Range"), key: :range},
range != [] range != []
) )
|> TableComponent.maybe_compose_columns(%{label: gettext("CPR"), key: :cpr}) |> TableComponent.maybe_compose_columns(
|> TableComponent.maybe_compose_columns(%{label: gettext("Price paid"), key: :price_paid}) %{label: gettext("Lot number"), key: :lot_number},
lot_number_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("CPR"), key: :cpr},
price_paid_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Price paid"), key: :price_paid},
price_paid_used
)
|> TableComponent.maybe_compose_columns( |> TableComponent.maybe_compose_columns(
%{label: gettext("% left"), key: :remaining}, %{label: gettext("% left"), key: :remaining},
show_used show_used
@ -93,33 +105,33 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
key: :count key: :count
}) })
|> TableComponent.maybe_compose_columns( |> TableComponent.maybe_compose_columns(
%{label: gettext("Ammo type"), key: :ammo_type}, %{label: gettext("Type"), key: :type},
ammo_type != [] type != []
) )
containers = containers =
ammo_groups packs
|> Enum.map(fn %{container_id: container_id} -> container_id end) |> Enum.map(fn %{container_id: container_id} -> container_id end)
|> Containers.get_containers(current_user) |> Containers.get_containers(current_user)
extra_data = %{ extra_data = %{
current_user: current_user, current_user: current_user,
ammo_type: ammo_type, type: type,
columns: columns, columns: columns,
container: container, container: container,
containers: containers, containers: containers,
original_counts: Ammo.get_original_counts(ammo_groups, current_user), original_counts: Ammo.get_original_counts(packs, current_user),
cprs: Ammo.get_cprs(ammo_groups, current_user), cprs: Ammo.get_cprs(packs, current_user),
last_used_dates: ActivityLog.get_last_used_dates(ammo_groups, current_user), last_used_dates: ActivityLog.get_last_used_dates(packs, current_user),
percentages_remaining: Ammo.get_percentages_remaining(ammo_groups, current_user), percentages_remaining: Ammo.get_percentages_remaining(packs, current_user),
actions: actions, actions: actions,
range: range range: range
} }
rows = rows =
ammo_groups packs
|> Enum.map(fn ammo_group -> |> Enum.map(fn pack ->
ammo_group |> get_row_data_for_ammo_group(extra_data) pack |> get_row_data_for_pack(extra_data)
end) end)
socket |> assign(columns: columns, rows: rows) socket |> assign(columns: columns, rows: rows)
@ -129,31 +141,36 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> <.live_component
module={TableComponent}
id={"pack-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div> </div>
""" """
end end
@spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map() @spec get_row_data_for_pack(Pack.t(), additional_data :: map()) :: map()
defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do defp get_row_data_for_pack(pack, %{columns: columns} = additional_data) do
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
{key, get_value_for_key(key, ammo_group, additional_data)} {key, get_value_for_key(key, pack, additional_data)}
end) end)
end end
@spec get_value_for_key(atom(), AmmoGroup.t(), additional_data :: map()) :: @spec get_value_for_key(atom(), Pack.t(), additional_data :: map()) ::
any() | {any(), Rendered.t()} any() | {any(), Rendered.t()}
defp get_value_for_key( defp get_value_for_key(
:ammo_type, :type,
%{ammo_type: %{name: ammo_type_name} = ammo_type}, %{type: %{name: type_name} = type},
%{ammo_type: ammo_type_block} %{type: type_block}
) do ) do
assigns = %{ammo_type: ammo_type, ammo_type_block: ammo_type_block} assigns = %{type: type, type_block: type_block}
{ammo_type_name, {type_name,
~H""" ~H"""
<%= render_slot(@ammo_type_block, @ammo_type) %> {render_slot(@type_block, @type)}
"""} """}
end end
@ -170,43 +187,42 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
"""} """}
end end
defp get_value_for_key(:used_up_on, %{id: ammo_group_id}, %{last_used_dates: last_used_dates}) do defp get_value_for_key(:used_up_on, %{id: pack_id}, %{last_used_dates: last_used_dates}) do
last_used_date = last_used_dates |> Map.get(ammo_group_id) last_used_date = last_used_dates |> Map.get(pack_id)
assigns = %{id: ammo_group_id, last_used_date: last_used_date} assigns = %{id: pack_id, last_used_date: last_used_date}
{last_used_date, {last_used_date,
~H""" ~H"""
<%= if @last_used_date do %> <%= if @last_used_date do %>
<.date id={"#{@id}-last-used-date"} date={@last_used_date} /> <.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %> <% else %>
<%= gettext("Never used") %> {gettext("Never used")}
<% end %> <% end %>
"""} """}
end end
defp get_value_for_key(:range, %{staged: staged} = ammo_group, %{range: range}) do defp get_value_for_key(:range, pack, %{range: range}) do
assigns = %{range: range, ammo_group: ammo_group} assigns = %{range: range, pack: pack}
{staged, ~H"""
~H""" {render_slot(@range, @pack)}
<%= render_slot(@range, @ammo_group) %> """
"""}
end end
defp get_value_for_key( defp get_value_for_key(
:remaining, :remaining,
%{id: ammo_group_id}, %{id: pack_id},
%{percentages_remaining: percentages_remaining} %{percentages_remaining: percentages_remaining}
) do ) do
percentage = Map.fetch!(percentages_remaining, ammo_group_id) percentage = Map.fetch!(percentages_remaining, pack_id)
{percentage, gettext("%{percentage}%", percentage: percentage)} {percentage, gettext("%{percentage}%", percentage: percentage)}
end end
defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do defp get_value_for_key(:actions, pack, %{actions: actions}) do
assigns = %{actions: actions, ammo_group: ammo_group} assigns = %{actions: actions, pack: pack}
~H""" ~H"""
<%= render_slot(@actions, @ammo_group) %> {render_slot(@actions, @pack)}
""" """
end end
@ -214,7 +230,7 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
defp get_value_for_key( defp get_value_for_key(
:container, :container,
%{container_id: container_id} = ammo_group, %{container_id: container_id} = pack,
%{container: container_block, containers: containers} %{container: container_block, containers: containers}
) do ) do
container = %{name: container_name} = Map.fetch!(containers, container_id) container = %{name: container_name} = Map.fetch!(containers, container_id)
@ -222,35 +238,35 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
assigns = %{ assigns = %{
container: container, container: container,
container_block: container_block, container_block: container_block,
ammo_group: ammo_group pack: pack
} }
{container_name, {container_name,
~H""" ~H"""
<%= render_slot(@container_block, {@ammo_group, @container}) %> {render_slot(@container_block, {@pack, @container})}
"""} """}
end end
defp get_value_for_key( defp get_value_for_key(
:original_count, :original_count,
%{id: ammo_group_id}, %{id: pack_id},
%{original_counts: original_counts} %{original_counts: original_counts}
) do ) do
Map.fetch!(original_counts, ammo_group_id) Map.fetch!(original_counts, pack_id)
end end
defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data),
do: {0, gettext("No cost information")} do: {0, gettext("No cost information")}
defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do defp get_value_for_key(:cpr, %{id: pack_id}, %{cprs: cprs}) do
amount = Map.fetch!(cprs, ammo_group_id) amount = Map.fetch!(cprs, pack_id)
{amount, gettext("$%{amount}", amount: display_currency(amount))} {amount, gettext("$%{amount}", amount: display_currency(amount))}
end end
defp get_value_for_key(:count, %{count: count}, _additional_data), defp get_value_for_key(:count, %{count: count}, _additional_data),
do: if(count == 0, do: {0, gettext("Empty")}, else: count) do: if(count == 0, do: {0, gettext("Empty")}, else: count)
defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key) defp get_value_for_key(key, pack, _additional_data), do: pack |> Map.get(key)
@spec display_currency(float()) :: String.t() @spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)

View File

@ -1,9 +1,9 @@
defmodule CanneryWeb.Components.ShotGroupTableComponent do defmodule CanneryWeb.Components.ShotRecordTableComponent do
@moduledoc """ @moduledoc """
A component that displays a list of shot groups A component that displays a list of shot records
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, ComparableDate} alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Ammo, ComparableDate}
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -12,26 +12,29 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
%{ %{
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:shot_groups) => [ShotGroup.t()], optional(:shot_records) => [ShotRecord.t()],
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{id: _id, shot_groups: _shot_groups, current_user: _current_user} = assigns, socket) do def update(
%{id: _id, shot_records: _shot_records, current_user: _current_user} = assigns,
socket
) do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_shot_groups() |> display_shot_records()
{:ok, socket} {:ok, socket}
end end
defp display_shot_groups( defp display_shot_records(
%{ %{
assigns: %{ assigns: %{
shot_groups: shot_groups, shot_records: shot_records,
current_user: current_user, current_user: current_user,
actions: actions actions: actions
} }
@ -45,17 +48,17 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
%{label: gettext("Actions"), key: :actions, sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
ammo_groups = packs =
shot_groups shot_records
|> Enum.map(fn %{ammo_group_id: ammo_group_id} -> ammo_group_id end) |> Enum.map(fn %{pack_id: pack_id} -> pack_id end)
|> Ammo.get_ammo_groups(current_user) |> Ammo.get_packs(current_user)
extra_data = %{current_user: current_user, actions: actions, ammo_groups: ammo_groups} extra_data = %{current_user: current_user, actions: actions, packs: packs}
rows = rows =
shot_groups shot_records
|> Enum.map(fn shot_group -> |> Enum.map(fn shot_record ->
shot_group |> get_row_data_for_shot_group(columns, extra_data) shot_record |> get_row_data_for_shot_record(columns, extra_data)
end) end)
socket socket
@ -71,7 +74,7 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component <.live_component
module={CanneryWeb.Components.TableComponent} module={CanneryWeb.Components.TableComponent}
id={"table-#{@id}"} id={"shot-record-table-#{@id}"}
columns={@columns} columns={@columns}
rows={@rows} rows={@rows}
initial_key={:date} initial_key={:date}
@ -81,22 +84,22 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
""" """
end end
@spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) :: @spec get_row_data_for_shot_record(ShotRecord.t(), columns :: [map()], extra_data :: map()) ::
map() map()
defp get_row_data_for_shot_group(shot_group, columns, extra_data) do defp get_row_data_for_shot_record(shot_record, columns, extra_data) do
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
{key, get_row_value(key, shot_group, extra_data)} {key, get_row_value(key, shot_record, extra_data)}
end) end)
end end
defp get_row_value(:name, %{ammo_group_id: ammo_group_id}, %{ammo_groups: ammo_groups}) do defp get_row_value(:name, %{pack_id: pack_id}, %{packs: packs}) do
assigns = %{ammo_group: ammo_group = Map.fetch!(ammo_groups, ammo_group_id)} assigns = %{pack: pack = Map.fetch!(packs, pack_id)}
{ammo_group.ammo_type.name, {pack.type.name,
~H""" ~H"""
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link"> <.link navigate={~p"/ammo/show/#{@pack}"} class="link">
<%= @ammo_group.ammo_type.name %> {@pack.type.name}
</.link> </.link>
"""} """}
end end
@ -108,13 +111,13 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
"""} """}
end end
defp get_row_value(:actions, shot_group, %{actions: actions}) do defp get_row_value(:actions, shot_record, %{actions: actions}) do
assigns = %{actions: actions, shot_group: shot_group} assigns = %{actions: actions, shot_record: shot_record}
~H""" ~H"""
<%= render_slot(@actions, @shot_group) %> {render_slot(@actions, @shot_record)}
""" """
end end
defp get_row_value(key, shot_group, _extra_data), do: shot_group |> Map.get(key) defp get_row_value(key, shot_record, _extra_data), do: shot_record |> Map.get(key)
end end

View File

@ -20,6 +20,7 @@ defmodule CanneryWeb.Components.TableComponent do
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{ComparableDate, ComparableDateTime}
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
require Integer require Integer
@ -75,7 +76,7 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode: initial_sort_mode sort_mode: initial_sort_mode
) )
|> assign_new(:row_class, fn -> "bg-white" end) |> assign_new(:row_class, fn -> "bg-white" end)
|> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) |> assign_new(:alternate_row_class, fn -> "bg-zinc-200" end)
{:ok, socket} {:ok, socket}
end end
@ -110,7 +111,7 @@ defmodule CanneryWeb.Components.TableComponent do
end end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type)
when type in [Date, DateTime] do when type in [ComparableDate, ComparableDateTime, Date, DateTime] do
rows rows
|> Enum.sort_by( |> Enum.sort_by(
fn row -> fn row ->

View File

@ -1,4 +1,4 @@
<div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white"> <div id={@id} class="w-full overflow-x-auto border border-zinc-600 rounded-lg shadow-lg bg-white">
<table class="min-w-full table-auto text-center bg-white"> <table class="min-w-full table-auto text-center bg-white">
<thead class="border-b border-primary-600"> <thead class="border-b border-primary-600">
<tr> <tr>
@ -12,7 +12,7 @@
phx-target={@myself} phx-target={@myself}
> >
<i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
<span class={if @last_sort_key == key, do: "underline"}><%= label %></span> <span class={if @last_sort_key == key, do: "underline"}>{label}</span>
<%= if @last_sort_key == key do %> <%= if @last_sort_key == key do %>
<%= case @sort_mode do %> <%= case @sort_mode do %>
<% :asc -> %> <% :asc -> %>
@ -27,7 +27,7 @@
</th> </th>
<% else %> <% else %>
<th class={["p-2 cursor-not-allowed", column[:class]]}> <th class={["p-2 cursor-not-allowed", column[:class]]}>
<%= label %> {label}
</th> </th>
<% end %> <% end %>
<% end %> <% end %>
@ -41,9 +41,9 @@
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %> <%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %> <% {_custom_sort_value, value} -> %>
<%= value %> {value}
<% value -> %> <% value -> %>
<%= value %> {value}
<% end %> <% end %>
</td> </td>
</tr> </tr>

View File

@ -1,9 +1,9 @@
defmodule CanneryWeb.Components.AmmoTypeTableComponent do defmodule CanneryWeb.Components.TypeTableComponent do
@moduledoc """ @moduledoc """
A component that displays a list of ammo type A component that displays a list of types
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.AmmoType} alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.Type}
alias CanneryWeb.Components.TableComponent alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -13,33 +13,33 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
%{ %{
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:type) => AmmoType.type() | nil, optional(:class) => Type.class() | nil,
optional(:show_used) => boolean(), optional(:show_used) => boolean(),
optional(:ammo_types) => [AmmoType.t()], optional(:types) => [Type.t()],
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{id: _id, ammo_types: _ammo_types, current_user: _current_user} = assigns, socket) do def update(%{id: _id, types: _types, current_user: _current_user} = assigns, socket) do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:show_used, fn -> false end) |> assign_new(:show_used, fn -> false end)
|> assign_new(:type, fn -> :all end) |> assign_new(:class, fn -> :all end)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_ammo_types() |> display_types()
{:ok, socket} {:ok, socket}
end end
defp display_ammo_types( defp display_types(
%{ %{
assigns: %{ assigns: %{
ammo_types: ammo_types, types: types,
current_user: current_user, current_user: current_user,
show_used: show_used, show_used: show_used,
type: type, class: class,
actions: actions actions: actions
} }
} = socket } = socket
@ -48,18 +48,17 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
[ [
%{label: gettext("Cartridge"), key: :cartridge, type: :string}, %{label: gettext("Cartridge"), key: :cartridge, type: :string},
%{ %{
label: if(type == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")), label: if(class == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")),
key: :caliber, key: :caliber,
type: :string type: :string
}, },
%{label: gettext("Unfired shell length"), key: :unfired_length, type: :string}, %{label: gettext("Unfired shell length"), key: :unfired_length, type: :string},
%{label: gettext("Brass height"), key: :brass_height, type: :string}, %{label: gettext("Brass height"), key: :brass_height, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string}, %{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Grains"), key: :grains, type: :string}, %{label: gettext("Grains"), key: :grains, type: :string},
%{label: gettext("Bullet type"), key: :bullet_type, type: :string}, %{label: gettext("Bullet type"), key: :bullet_type, type: :string},
%{ %{
label: if(type == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")), label: if(class == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")),
key: :bullet_core, key: :bullet_core,
type: :string type: :string
}, },
@ -92,8 +91,8 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
# remove columns if all values match defaults # remove columns if all values match defaults
default_value = if type == :atom, do: false, else: nil default_value = if type == :atom, do: false, else: nil
ammo_types types
|> Enum.any?(fn ammo_type -> Map.get(ammo_type, key, default_value) != default_value end) |> Enum.any?(fn type -> Map.get(type, key, default_value) != default_value end)
end) end)
columns = columns =
@ -147,22 +146,30 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
}) })
|> TableComponent.maybe_compose_columns(filtered_columns) |> TableComponent.maybe_compose_columns(filtered_columns)
|> TableComponent.maybe_compose_columns( |> TableComponent.maybe_compose_columns(
%{label: gettext("Type"), key: :type, type: :atom}, %{label: gettext("Class"), key: :class, type: :atom},
type in [:all, nil] class in [:all, nil]
) )
|> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name})
round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user) round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user) packs_count = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) average_costs = Ammo.get_average_costs(types, current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do if show_used do
[ [
ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user), ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id),
ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user), Ammo.get_historical_counts(types, current_user),
ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true), Ammo.get_grouped_packs_count(current_user,
ammo_types |> Ammo.get_used_ammo_groups_count_for_types(current_user) types: types,
group_by: :type_id,
show_used: true
),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: :only_used
)
] ]
else else
[nil, nil, nil, nil] [nil, nil, nil, nil]
@ -181,9 +188,9 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
} }
rows = rows =
ammo_types types
|> Enum.map(fn ammo_type -> |> Enum.map(fn type ->
ammo_type |> get_ammo_type_values(columns, extra_data) type |> get_type_values(columns, extra_data)
end) end)
socket |> assign(columns: columns, rows: rows) socket |> assign(columns: columns, rows: rows)
@ -193,97 +200,100 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> <.live_component
module={TableComponent}
id={"type-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div> </div>
""" """
end end
defp get_ammo_type_values(ammo_type, columns, extra_data) do defp get_type_values(type, columns, extra_data) do
columns columns
|> Map.new(fn %{key: key, type: type} -> |> Map.new(fn %{key: key, type: column_type} ->
{key, get_ammo_type_value(type, key, ammo_type, extra_data)} {key, get_type_value(column_type, key, type, extra_data)}
end) end)
end end
defp get_ammo_type_value(:atom, key, ammo_type, _other_data), defp get_type_value(:atom, key, type, _other_data),
do: ammo_type |> Map.get(key) |> humanize() do: type |> Map.get(key) |> humanize()
defp get_ammo_type_value(:round_count, _key, %{id: ammo_type_id}, %{round_counts: round_counts}), defp get_type_value(:round_count, _key, %{id: type_id}, %{round_counts: round_counts}),
do: Map.get(round_counts, ammo_type_id, 0) do: Map.get(round_counts, type_id, 0)
defp get_ammo_type_value( defp get_type_value(
:historical_round_count, :historical_round_count,
_key, _key,
%{id: ammo_type_id}, %{id: type_id},
%{historical_round_counts: historical_round_counts} %{historical_round_counts: historical_round_counts}
) do ) do
Map.get(historical_round_counts, ammo_type_id, 0) Map.get(historical_round_counts, type_id, 0)
end end
defp get_ammo_type_value( defp get_type_value(
:used_round_count, :used_round_count,
_key, _key,
%{id: ammo_type_id}, %{id: type_id},
%{used_counts: used_counts} %{used_counts: used_counts}
) do ) do
Map.get(used_counts, ammo_type_id, 0) Map.get(used_counts, type_id, 0)
end end
defp get_ammo_type_value( defp get_type_value(
:historical_pack_count, :historical_pack_count,
_key, _key,
%{id: ammo_type_id}, %{id: type_id},
%{historical_pack_counts: historical_pack_counts} %{historical_pack_counts: historical_pack_counts}
) do ) do
Map.get(historical_pack_counts, ammo_type_id, 0) Map.get(historical_pack_counts, type_id, 0)
end end
defp get_ammo_type_value( defp get_type_value(
:used_pack_count, :used_pack_count,
_key, _key,
%{id: ammo_type_id}, %{id: type_id},
%{used_pack_counts: used_pack_counts} %{used_pack_counts: used_pack_counts}
) do ) do
Map.get(used_pack_counts, ammo_type_id, 0) Map.get(used_pack_counts, type_id, 0)
end end
defp get_ammo_type_value(:ammo_count, _key, %{id: ammo_type_id}, %{packs_count: packs_count}), defp get_type_value(:ammo_count, _key, %{id: type_id}, %{packs_count: packs_count}),
do: Map.get(packs_count, ammo_type_id) do: Map.get(packs_count, type_id)
defp get_ammo_type_value( defp get_type_value(
:avg_price_paid, :avg_price_paid,
_key, _key,
%{id: ammo_type_id}, %{id: type_id},
%{average_costs: average_costs} %{average_costs: average_costs}
) do ) do
case Map.get(average_costs, ammo_type_id) do case Map.get(average_costs, type_id) do
nil -> {0, gettext("No cost information")} nil -> {0, gettext("No cost information")}
count -> {count, gettext("$%{amount}", amount: display_currency(count))} count -> {count, gettext("$%{amount}", amount: display_currency(count))}
end end
end end
defp get_ammo_type_value(:name, _key, %{name: ammo_type_name} = ammo_type, _other_data) do defp get_type_value(:name, _key, %{name: type_name} = assigns, _other_data) do
assigns = %{ammo_type: ammo_type} {type_name,
{ammo_type_name,
~H""" ~H"""
<.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link"> <.link navigate={~p"/type/#{@id}"} class="link">
<%= @ammo_type.name %> {@name}
</.link> </.link>
"""} """}
end end
defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do defp get_type_value(:actions, _key, type, %{actions: actions}) do
assigns = %{actions: actions, ammo_type: ammo_type} assigns = %{actions: actions, type: type}
~H""" ~H"""
<%= render_slot(@actions, @ammo_type) %> {render_slot(@actions, @type)}
""" """
end end
defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil defp get_type_value(nil, _key, _type, _other_data), do: nil
defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key) defp get_type_value(_other, key, type, _other_data), do: type |> Map.get(key)
@spec display_currency(float()) :: String.t() @spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)

View File

@ -6,7 +6,8 @@ defmodule CanneryWeb.EmailController do
use CanneryWeb, :controller use CanneryWeb, :controller
alias Cannery.Accounts.User alias Cannery.Accounts.User
plug :put_layout, {CanneryWeb.LayoutView, :email} plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html}
plug :put_layout, false
@sample_assigns %{ @sample_assigns %{
email: %{subject: "Example subject"}, email: %{subject: "Example subject"},
@ -18,6 +19,6 @@ defmodule CanneryWeb.EmailController do
Debug route used to preview emails Debug route used to preview emails
""" """
def preview(conn, %{"id" => template}) do def preview(conn, %{"id" => template}) do
render(conn, "#{template |> to_string()}.html", @sample_assigns) render(conn, String.to_existing_atom(template), @sample_assigns)
end end
end end

View File

@ -0,0 +1,16 @@
defmodule CanneryWeb.ErrorHTML do
use CanneryWeb, :html
embed_templates "error_html/*"
def render(template, _assigns) do
error_string =
case template do
"404.html" -> dgettext("errors", "Not found")
"401.html" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
error(%{error_string: error_string})
end
end

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title> <title>
<%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %> {dgettext("errors", "Error")} | {gettext("Cannery")}
</title> </title>
<link rel="stylesheet" href="/css/app.css" /> <link rel="stylesheet" href="/css/app.css" />
<script defer type="text/javascript" src="/js/app.js"> <script defer type="text/javascript" src="/js/app.js">
@ -19,16 +19,13 @@
<div class="pb-8 w-full flex flex-col justify-center items-center text-center"> <div class="pb-8 w-full flex flex-col justify-center items-center text-center">
<div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl"> <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl">
<h1 class="title text-primary-600 text-3xl"> <h1 class="title text-primary-600 text-3xl">
<%= @error_string %> {@error_string}
</h1> </h1>
<hr class="w-full hr" /> <hr class="w-full hr" />
<.link <.link href={~p"/"} class="link title text-primary-600 text-lg">
href={Routes.live_path(Endpoint, HomeLive)} {dgettext("errors", "Go back home")}
class="link title text-primary-600 text-lg"
>
<%= dgettext("errors", "Go back home") %>
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -0,0 +1,14 @@
defmodule CanneryWeb.ErrorJSON do
use Gettext, backend: CanneryWeb.Gettext
def render(template, _assigns) do
error_string =
case template do
"404.json" -> dgettext("errors", "Not found")
"401.json" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
%{errors: %{detail: error_string}}
end
end

View File

@ -3,73 +3,80 @@ defmodule CanneryWeb.ExportController do
alias Cannery.{ActivityLog, Ammo, Containers} alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
ammo_types = Ammo.list_ammo_types(current_user, :all) types = Ammo.list_types(current_user)
used_counts = ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user)
round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user)
ammo_group_counts = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user)
total_ammo_group_counts = used_counts =
ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true) ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
ammo_types = total_pack_counts =
ammo_types Ammo.get_grouped_packs_count(current_user,
|> Enum.map(fn %{id: ammo_type_id} = ammo_type -> types: types,
ammo_type group_by: :type_id,
show_used: true
)
average_costs = Ammo.get_average_costs(types, current_user)
types =
types
|> Enum.map(fn %{id: type_id} = type ->
type
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"average_cost" => Map.get(average_costs, ammo_type_id), "average_cost" => Map.get(average_costs, type_id),
"round_count" => Map.get(round_counts, ammo_type_id, 0), "round_count" => Map.get(round_counts, type_id, 0),
"used_count" => Map.get(used_counts, ammo_type_id, 0), "used_count" => Map.get(used_counts, type_id, 0),
"ammo_group_count" => Map.get(ammo_group_counts, ammo_type_id, 0), "pack_count" => Map.get(pack_counts, type_id, 0),
"total_ammo_group_count" => Map.get(total_ammo_group_counts, ammo_type_id, 0) "total_pack_count" => Map.get(total_pack_counts, type_id, 0)
}) })
end) end)
ammo_groups = Ammo.list_ammo_groups(nil, :all, current_user, true) packs = Ammo.list_packs(current_user, show_used: true)
used_counts = ammo_groups |> ActivityLog.get_used_counts(current_user)
original_counts = ammo_groups |> Ammo.get_original_counts(current_user)
cprs = ammo_groups |> Ammo.get_cprs(current_user)
percentages_remaining = ammo_groups |> Ammo.get_percentages_remaining(current_user)
ammo_groups = used_counts =
ammo_groups ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id)
|> Enum.map(fn %{id: ammo_group_id} = ammo_group ->
ammo_group original_counts = packs |> Ammo.get_original_counts(current_user)
cprs = packs |> Ammo.get_cprs(current_user)
percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user)
packs =
packs
|> Enum.map(fn %{id: pack_id} = pack ->
pack
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"used_count" => Map.get(used_counts, ammo_group_id), "used_count" => Map.get(used_counts, pack_id),
"percentage_remaining" => Map.fetch!(percentages_remaining, ammo_group_id), "percentage_remaining" => Map.fetch!(percentages_remaining, pack_id),
"original_count" => Map.get(original_counts, ammo_group_id), "original_count" => Map.get(original_counts, pack_id),
"cpr" => Map.get(cprs, ammo_group_id) "cpr" => Map.get(cprs, pack_id)
}) })
end) end)
shot_groups = ActivityLog.list_shot_groups(:all, current_user) shot_records = ActivityLog.list_shot_records(current_user)
containers = containers =
Containers.list_containers(current_user) Containers.list_containers(current_user)
|> Enum.map(fn container -> |> Enum.map(fn container ->
ammo_group_count = container |> Ammo.get_ammo_groups_count_for_container!(current_user)
round_count = container |> Ammo.get_round_count_for_container!(current_user)
container container
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"ammo_group_count" => ammo_group_count, "pack_count" => Ammo.get_packs_count(current_user, container_id: container.id),
"round_count" => round_count "round_count" => Ammo.get_round_count(current_user, container_id: container.id)
}) })
end) end)
json(conn, %{ json(conn, %{
user: current_user, user: current_user,
ammo_types: ammo_types, types: types,
ammo_groups: ammo_groups, packs: packs,
shot_groups: shot_groups, shot_records: shot_records,
containers: containers containers: containers
}) })
end end

View File

@ -1,11 +0,0 @@
defmodule CanneryWeb.HomeController do
@moduledoc """
Controller for home page
"""
use CanneryWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -0,0 +1,5 @@
defmodule CanneryWeb.HomeHTML do
use CanneryWeb, :html
embed_templates "home_html/*"
end

View File

@ -3,12 +3,11 @@ defmodule CanneryWeb.UserAuth do
Functions for user session and authentication Functions for user session and authentication
""" """
use CanneryWeb, :verified_routes
use Gettext, backend: CanneryWeb.Gettext
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.User} alias Cannery.{Accounts, Accounts.User}
alias CanneryWeb.HomeLive
alias CanneryWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days. # Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change # If you want bump or reduce this value, also change
@ -39,7 +38,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.") dgettext("errors", "You must confirm your account and log in to access this page.")
) )
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new)) |> redirect(to: ~p"/users/log_in")
|> halt() |> halt()
end end
@ -49,8 +48,7 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> put_session(:user_token, token) |> put_token_in_session(token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params) |> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn)) |> redirect(to: user_return_to || signed_in_path(conn))
end end
@ -96,7 +94,7 @@ defmodule CanneryWeb.UserAuth do
""" """
def log_out_user(conn) do def log_out_user(conn) do
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token) user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do if live_socket_id = get_session(conn, :live_socket_id) do
CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -105,7 +103,7 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> delete_resp_cookie(@remember_me_cookie) |> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/") |> redirect(to: ~p"/")
end end
@doc """ @doc """
@ -119,19 +117,110 @@ defmodule CanneryWeb.UserAuth do
end end
defp ensure_user_token(conn) do defp ensure_user_token(conn) do
if user_token = get_session(conn, :user_token) do if token = get_session(conn, :user_token) do
{user_token, conn} {token, conn}
else else
conn = fetch_cookies(conn, signed: [@remember_me_cookie]) conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do if token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)} {token, put_token_in_session(conn, token)}
else else
{nil, conn} {nil, conn}
end end
end end
end end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule CanneryWeb.PageLive do
use CanneryWeb, :live_view
on_mount {CanneryWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{CanneryWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(session, socket)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:ensure_admin, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user && socket.assigns.current_user.role == :admin do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in as an administrator to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(session, socket) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """ @doc """
Used for routes that require the user to not be authenticated. Used for routes that require the user to not be authenticated.
""" """
@ -161,7 +250,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.") dgettext("errors", "You must confirm your account and log in to access this page.")
) )
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new)) |> redirect(to: ~p"/users/log_in")
|> halt() |> halt()
end end
end end
@ -176,16 +265,34 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) |> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: Routes.live_path(conn, HomeLive)) |> redirect(to: ~p"/")
|> halt() |> halt()
end end
end end
def put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(locale || default)
conn |> put_session(:locale, locale || default)
end
def put_user_locale(conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(default)
conn |> put_session(:locale, default)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn)) put_session(conn, :user_return_to, current_path(conn))
end end
defp maybe_store_return_to(conn), do: conn defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/" defp signed_in_path(_conn), do: ~p"/"
end end

View File

@ -1,18 +1,16 @@
defmodule CanneryWeb.UserConfirmationController do defmodule CanneryWeb.UserConfirmationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.Accounts alias Cannery.Accounts
def new(conn, _params) do def new(conn, _params) do
render(conn, "new.html", page_title: gettext("Confirm your account")) render(conn, :new, page_title: gettext("Confirm your account"))
end end
def create(conn, %{"user" => %{"email" => email}}) do def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
&Routes.user_confirmation_url(conn, :confirm, &1) fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
) )
end end
@ -22,11 +20,10 @@ defmodule CanneryWeb.UserConfirmationController do
:info, :info,
dgettext( dgettext(
"prompts", "prompts",
"If your email is in our system and it has not been confirmed yet, " <> "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
"you will receive an email with instructions shortly."
) )
) )
|> redirect(to: "/") |> redirect(to: ~p"/")
end end
# Do not log in the user after confirmation to avoid a # Do not log in the user after confirmation to avoid a
@ -36,7 +33,7 @@ defmodule CanneryWeb.UserConfirmationController do
{:ok, %{email: email}} -> {:ok, %{email: email}} ->
conn conn
|> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
|> redirect(to: "/") |> redirect(to: ~p"/")
:error -> :error ->
# If there is a current user and the account was already confirmed, # If there is a current user and the account was already confirmed,
@ -45,7 +42,7 @@ defmodule CanneryWeb.UserConfirmationController do
# a warning message. # a warning message.
case conn.assigns do case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: "/") redirect(conn, to: ~p"/")
%{} -> %{} ->
conn conn
@ -53,7 +50,7 @@ defmodule CanneryWeb.UserConfirmationController do
:error, :error,
dgettext("errors", "User confirmation link is invalid or it has expired.") dgettext("errors", "User confirmation link is invalid or it has expired.")
) )
|> redirect(to: "/") |> redirect(to: ~p"/")
end end
end end
end end

View File

@ -0,0 +1,6 @@
defmodule CanneryWeb.UserConfirmationHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_confirmation_html/*"
end

View File

@ -0,0 +1,31 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl">
{dgettext("actions", "Resend confirmation instructions")}
</h1>
<.form
:let={f}
for={%{}}
as={:user}
action={~p"/users/confirm"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
{label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
{email_input(f, :email, required: true, class: "input input-primary col-span-2")}
{submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
{dgettext("actions", "Register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "Log in")}
</.link>
</div>
</div>

View File

@ -1,8 +1,6 @@
defmodule CanneryWeb.UserRegistrationController do defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.Invites} alias Cannery.{Accounts, Accounts.Invites}
alias CanneryWeb.{Endpoint, HomeLive}
alias Ecto.Changeset alias Ecto.Changeset
def new(conn, %{"invite" => invite_token}) do def new(conn, %{"invite" => invite_token}) do
@ -11,7 +9,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive)) |> redirect(to: ~p"/")
end end
end end
@ -21,13 +19,13 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive)) |> redirect(to: ~p"/")
end end
end end
# renders new user registration page # renders new user registration page
defp render_new(conn, invite_token \\ nil) do defp render_new(conn, invite_token \\ nil) do
render(conn, "new.html", render(conn, :new,
changeset: Accounts.change_user_registration(), changeset: Accounts.change_user_registration(),
invite_token: invite_token, invite_token: invite_token,
page_title: gettext("Register") page_title: gettext("Register")
@ -40,7 +38,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive)) |> redirect(to: ~p"/")
end end
end end
@ -50,7 +48,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive)) |> redirect(to: ~p"/")
end end
end end
@ -59,20 +57,20 @@ defmodule CanneryWeb.UserRegistrationController do
{:ok, user} -> {:ok, user} ->
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
&Routes.user_confirmation_url(conn, :confirm, &1) fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
) )
conn conn
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|> redirect(to: Routes.user_session_path(Endpoint, :new)) |> redirect(to: ~p"/users/log_in")
{:error, :invalid_token} -> {:error, :invalid_token} ->
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive)) |> redirect(to: ~p"/")
{:error, %Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
conn |> render("new.html", changeset: changeset, invite_token: invite_token) conn |> render(:new, changeset: changeset, invite_token: invite_token)
end end
end end
end end

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