Compare commits
	
		
			235 Commits
		
	
	
		
			0.6.0
			...
			81f21a02c4
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 81f21a02c4 | |||
| 9a17d4ad24 | |||
| 9835fe3f5e | |||
| 4dee8808f3 | |||
| 65c70ca398 | |||
| 550f6a6420 | |||
| 88c3f15fc8 | |||
| c33f15603b | |||
| 98775359af | |||
| e0e7b25bc4 | |||
| bdddf65685 | |||
| 6f50702b11 | |||
| 5f8d1a917f | |||
| 32801828fa | |||
| 6ed3312ea8 | |||
| b122253b9b | |||
| a68a16bf06 | |||
| 4b6d0952f8 | |||
| 0544b58ab6 | |||
| 6d26103784 | |||
| 0cae7c2940 | |||
| 1e645b5bb8 | |||
| bab2b26c13 | |||
| 8c95536ffd | |||
| d9251c7e4c | |||
| fe4e4f4f17 | |||
| e5e5449e8b | |||
| 355752598c | |||
| 03f8a2e8a7 | |||
| 071eb1b3c9 | |||
| 2987e4ff37 | |||
| ca81924ebe | |||
| 40e4f6fe0a | |||
| 213dcca973 | |||
| b32edd581d | |||
| 2e372ca2ab | |||
| fd0bac3bbf | |||
| f83fbc5d99 | |||
| daab051026 | |||
| 440dc5061b | |||
| c0d2c69144 | |||
| 7a7359fa66 | |||
| 9e8fd00d65 | |||
| f5f72b53e6 | |||
| a54cf8b87d | |||
| 0b7146ba32 | |||
| c0441957b6 | |||
| 7fa9933a9b | |||
| f4c7f22460 | |||
| a01d97e360 | |||
|  | a53b352cf7 | ||
| ce07cc2569 | |||
| 3acecb9a93 | |||
| ab8561fcf0 | |||
| 8163b906a2 | |||
| b29a5cce7b | |||
| 9205a04ac5 | |||
| 632a9e1379 | |||
| 92cc49630d | |||
| a778f5a61f | |||
| 07ff796553 | |||
| 07b31fcc86 | |||
| bc034c0361 | |||
| eb9280fa7e | |||
| ad1e44fd42 | |||
| 4e9f66f006 | |||
| 81350f9898 | |||
| 9c4a32896f | |||
| 56dae6cdfe | |||
| 8ef3bd65a3 | |||
| c8cadd6246 | |||
| 3f143262d4 | |||
| ebe09bcf84 | |||
| 42e2d1c76e | |||
| 064d2d3988 | |||
| fd0b2c455a | |||
| f1139d0ec4 | |||
| f6b5fc17fa | |||
| cd6bb6fbc3 | |||
| 2c0a4dd7ca | |||
| 5c05f3b6fe | |||
| 30d3f76fe1 | |||
| ed8c20e967 | |||
| 47dab6490d | |||
| 7b60938a75 | |||
| f19d024d8a | |||
| 084173909e | |||
| 1fbed50b0f | |||
| 2f8af8ae4f | |||
| 737484c36e | |||
| 2cf705c46f | |||
| 725df05521 | |||
| 3ae890c193 | |||
| 6dbadc58ae | |||
| 75fcbb1e65 | |||
| e568a2f073 | |||
| 0b27c8f80b | |||
| f155a43ee8 | |||
| 0ad1ee47de | |||
| 8ea2b06487 | |||
| bbaa1dfd6b | |||
| 2c2b9fefc9 | |||
| bafc824a32 | |||
| 8c2f7e0509 | |||
| 1e4accec9d | |||
| 076d5eea18 | |||
| 5b6bd00047 | |||
| 22abc7a8d0 | |||
| 5a685ac00e | |||
| 8dd471afa8 | |||
| a5c12b3e17 | |||
| 27af5acf8b | |||
| 469428c007 | |||
| 09d3754f92 | |||
| fa67fd5a3b | |||
| 0a56b32b17 | |||
| 1cd28e43b8 | |||
| 5f214ab7f9 | |||
| 0c95fa88f4 | |||
| 61f8776d2e | |||
| 695002c9d9 | |||
| dde60d71d1 | |||
| 91794ddc55 | |||
| 1e3cec95fe | |||
| f0a8c515f9 | |||
| e99775eef2 | |||
| 6760f83ca0 | |||
| 10877bb754 | |||
| 38a581b639 | |||
| 9408705430 | |||
| 302aa7eeda | |||
| fd4fdcc36b | |||
| 3cb723b9e4 | |||
| 1f92c452d1 | |||
| c10cff63ea | |||
| 70faed71d0 | |||
| b5c46c09ec | |||
| 7745765fc0 | |||
| e16fbba810 | |||
| e35bdf101b | |||
| aa314e5ca1 | |||
| 616de3c117 | |||
| 74bcec6cfe | |||
| 41090c46d0 | |||
| c3f5744ad6 | |||
| 95a339fe02 | |||
| 1e3b027367 | |||
| dd46e1795f | |||
| 9e517e6477 | |||
| 26f75c7d15 | |||
| 23f7af2a93 | |||
| 8e771a2d9a | |||
| 16155687c9 | |||
| ef0d644ee3 | |||
| faacf80b51 | |||
| 7a7754767f | |||
| 0894293620 | |||
| 58b32817ba | |||
| 45da547f62 | |||
| 3ea4b77b67 | |||
| d743336868 | |||
| c99fbabcaa | |||
| 95642061db | |||
| 11ef53d1bf | |||
| 757eca47f7 | |||
| 7191fe8e4b | |||
|  | 02253c74ac | ||
|  | fa32b2a073 | ||
|  | aa0ea10fa4 | ||
|  | 72ac611e15 | ||
|  | ff0af84d5d | ||
|  | 851d0954ad | ||
|  | ea4336adc5 | ||
|  | fcce6a6f40 | ||
|  | 4d3085da2a | ||
| 2ec60ba342 | |||
| e0ac714681 | |||
| 20a2311229 | |||
| a6ae8a872d | |||
| 81e448afc4 | |||
| dfebad713f | |||
| bf0ea4168b | |||
| 5ffa627beb | |||
| 25d4f1916a | |||
| fd583f2941 | |||
| 5cae646758 | |||
| d389515f76 | |||
| c2568b6521 | |||
| 60462877c5 | |||
| ba5a4e69b2 | |||
|  | ba36b4e4c5 | ||
|  | f5a092e91f | ||
| 09774e0aed | |||
| 44d4d7c6f9 | |||
| 1fed895b82 | |||
| 3480aff61d | |||
| 1ea4b99c81 | |||
| 2191e7f4f4 | |||
| 8231683958 | |||
| 34118299e9 | |||
| 97a9b6d51a | |||
| 059004ba78 | |||
| 5d02ed6369 | |||
| ec6acdbb5d | |||
| 33d82a902d | |||
| fc5b03d680 | |||
| c918dbe4bf | |||
| 23b60e032d | |||
| 7283932d85 | |||
| 8ff1fd0276 | |||
| 4ef09f5279 | |||
| 9734be4966 | |||
| 485965d9c9 | |||
| e9cdb0f717 | |||
| 1c07449b54 | |||
| b64e85f65c | |||
| 9387756109 | |||
| f1f3082368 | |||
| 50a8a79596 | |||
| 67b48e1a3f | |||
| 67c30d7f88 | |||
| 728728a5a4 | |||
| a64d92a6cf | |||
| 6227d64072 | |||
| 46eed25a94 | |||
| 3674eeaf5a | |||
| f0676a2433 | |||
| a72a4b0cbe | |||
| a2dea04668 | |||
| 3dc255b7c2 | |||
| bde1cff7a4 | |||
| 66cc11e9eb | |||
| 81a250206e | |||
| 6d5f7f68df | |||
| 5b5f1ce1e5 | 
							
								
								
									
										19
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -13,20 +13,24 @@ steps: | |||||||
|     mount: |     mount: | ||||||
|       - _build |       - _build | ||||||
|       - deps |       - deps | ||||||
|       - assets/node_modules/ |       - .npm | ||||||
|  |       - .mix | ||||||
|  |  | ||||||
| - name: test | - name: test | ||||||
|   image: elixir:1.14.1-alpine |   image: elixir:1.14.1-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 | ||||||
|  |     MIX_HOME: /drone/src/.mix | ||||||
|  |     MIX_ARCHIVES: /drone/src/.mix/archives | ||||||
|  |     MIX_ENV: test | ||||||
|   commands: |   commands: | ||||||
|   - apk add --no-cache build-base npm git python3 |   - apk add --no-cache build-base npm git | ||||||
|   - mix local.rebar --force |   - mix local.rebar --force --if-missing | ||||||
|   - mix local.hex --force |   - mix local.hex --force --if-missing | ||||||
|   - mix deps.get |   - mix deps.get | ||||||
|   - mix deps.compile |   - npm set cache .npm | ||||||
|   - npm --prefix ./assets ci --progress=false --no-audit --loglevel=error |   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||||
|   - npm run --prefix ./assets deploy |   - npm run --prefix ./assets deploy | ||||||
|   - mix do phx.digest, gettext.extract |   - mix do phx.digest, gettext.extract | ||||||
|   - mix test.all |   - mix test.all | ||||||
| @@ -76,7 +80,8 @@ steps: | |||||||
|     mount: |     mount: | ||||||
|       - _build |       - _build | ||||||
|       - deps |       - deps | ||||||
|       - assets/node_modules/ |       - .npm | ||||||
|  |       - .mix | ||||||
|  |  | ||||||
| services: | services: | ||||||
| - name: database | - name: database | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -25,7 +25,7 @@ cannery-*.tar | |||||||
| # If NPM crashes, it generates a log, let's ignore it too. | # If NPM crashes, it generates a log, let's ignore it too. | ||||||
| npm-debug.log | npm-debug.log | ||||||
|  |  | ||||||
| # Ignore assets that are produced by build tools. | # The directory NPM downloads your dependencies sources to. | ||||||
| /assets/node_modules/ | /assets/node_modules/ | ||||||
|  |  | ||||||
| # Since we are building assets from assets/, | # Since we are building assets from assets/, | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| elixir 1.14.1-otp-25 | elixir 1.14.1-otp-25 | ||||||
| erlang 25.1.2 | erlang 25.1.2 | ||||||
| nodejs 18.12.1 | nodejs 18.9.1 | ||||||
|   | |||||||
							
								
								
									
										98
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										98
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,3 +1,95 @@ | |||||||
|  | # 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 | ||||||
|  |  | ||||||
|  | # 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 | ||||||
|  | - Add length limits to all string fields | ||||||
|  | - Add selectable ammo types | ||||||
|  | - Improve onboarding experience slightly | ||||||
|  | - Remove show used view from a container since it doesn't really make that much | ||||||
|  |   sense | ||||||
|  |  | ||||||
|  | # v0.8.6 | ||||||
|  | - Fix duplicate entries showing up | ||||||
|  | - Show ammo packs under a type in a table by default | ||||||
|  | - Only show historical ammo type information when displaying "Show used" in table | ||||||
|  | - Only show historical ammo pack information when displaying "Show used" in table | ||||||
|  | - Fix some values not being sorted in tables properly | ||||||
|  | - Code quality improvements | ||||||
|  | - Show link to ammo pack in ammo pack table while viewing ammo type | ||||||
|  |  | ||||||
|  | # v0.8.5 | ||||||
|  | - Add link in readme to github mirror | ||||||
|  | - Fix tables unable to sort on empty dates | ||||||
|  | - Only show historical ammo type information when displaying "Show used" | ||||||
|  | - Fix even more accessibility issues | ||||||
|  |  | ||||||
|  | # v0.8.4 | ||||||
|  | - Improve accessibility | ||||||
|  | - Code quality improvements | ||||||
|  | - Fix dead link of example bullet abbreviations | ||||||
|  | - Fix inaccurate error message when updating shot records | ||||||
|  | - Fix tables not sorting dates correctly | ||||||
|  | - Fix dates displaying incorrectly | ||||||
|  | - Fix container table not displaying all fields | ||||||
|  | - Fix textareas resizing when typing in them | ||||||
|  |  | ||||||
|  | # v0.8.3 | ||||||
|  | - Improve some styles | ||||||
|  | - Improve server log | ||||||
|  | - Various minor improvements | ||||||
|  |  | ||||||
|  | # v0.8.2 | ||||||
|  | - Fix bug with public registration | ||||||
|  | - Improve templates | ||||||
|  | - Improve invites, record usage | ||||||
|  | - Fix padding on more pages when using chrome | ||||||
|  | - Add oban metrics to server log and live dashboard | ||||||
|  |  | ||||||
|  | # v0.8.1 | ||||||
|  | - Update dependencies | ||||||
|  | - Show topbar on form submit/page refresh | ||||||
|  | - Make loading/reconnection less intrusive | ||||||
|  | - Add QR code for invite link | ||||||
|  |  | ||||||
|  | # v0.8.0 | ||||||
|  | - Add search to catalog, ammo, container, tag and range index pages | ||||||
|  | - Tweak urls for catalog, ammo, containers, tags and shot records | ||||||
|  | - Fix bug with shot record chart not drawing lines between days correctly | ||||||
|  | - Improve cards across app (make them line up with each other) | ||||||
|  | - Update translations and add spanish!!! (thank you Brea and Hannah!) | ||||||
|  |  | ||||||
|  | # v0.7.2 | ||||||
|  | - Code improvements | ||||||
|  |  | ||||||
|  | # v0.7.1 | ||||||
|  | - Fix table component alignment and styling | ||||||
|  | - Fix toggle button styling | ||||||
|  | - Miscellanous code improvements | ||||||
|  | - Improve container index table | ||||||
|  | - Fix bug with ammo not updating after deleting shot record | ||||||
|  | - Replace ammo "added on" with "purchased on" | ||||||
|  | - Miscellaneous wording improvements | ||||||
|  | - Update translations | ||||||
|  |  | ||||||
|  | # v0.7.0 | ||||||
|  | - Add shading to table component | ||||||
|  | - Fix chart to sum by day | ||||||
|  | - Fix whitespace when copying invite url | ||||||
|  | - Make ammo type show page also display packs as table | ||||||
|  | - Make container show page also display packs as table | ||||||
|  | - Display CPR for ammo packs | ||||||
|  | - Add original count for ammo packs | ||||||
|  | - Add ammo pack CPR and original count to json export | ||||||
|  |  | ||||||
| # v0.6.0 | # v0.6.0 | ||||||
| - Update translations | - Update translations | ||||||
| - Display used-up date on used-up ammo | - Display used-up date on used-up ammo | ||||||
| @@ -17,7 +109,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 | ||||||
| @@ -69,8 +161,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 | ||||||
|   | |||||||
| @@ -17,8 +17,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 `Changeset` in the app, please alias |   - I.e. since there's only one `Pack` in the app, please alias | ||||||
|     `Changeset.t(Type.t())` instead of using `Ecto.Changeset.t(Long.Type.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 | ||||||
| @@ -63,7 +63,8 @@ And as always, thank you! | |||||||
|   [`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/). |   [`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/). | ||||||
| - `Dockerfile` and example `docker-compose.yml` | - `Dockerfile` and example `docker-compose.yml` | ||||||
| - Automatic migrations in `MIX_ENV=prod` or Docker image | - Automatic migrations in `MIX_ENV=prod` or Docker image | ||||||
| - JS linting with [standard.js](https://standardjs.com) | - JS linting with [standard.js](https://standardjs.com), HEEx linting with | ||||||
|  |   [heex_formatter](https://github.com/feliperenan/heex_formatter) | ||||||
|  |  | ||||||
| ## Docs | ## Docs | ||||||
|  |  | ||||||
| @@ -109,7 +110,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime. | |||||||
| - `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`. | ||||||
| - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. | - `REGISTRATION`: Controls if user sign-up should be invite only or set to 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`. | ||||||
|   - Available options: `en_US`, `de`, and `fr` |   - Available options: `en_US`, `de`, `fr`, and `es` | ||||||
|  |  | ||||||
| ## `MIX_ENV=test` | ## `MIX_ENV=test` | ||||||
|  |  | ||||||
| @@ -144,3 +145,4 @@ Thank you so much for your contributions! | |||||||
| - kaia (https://shitposter.club/users/kaia) | - kaia (https://shitposter.club/users/kaia) | ||||||
| - duponin (https://udongein.xyz/users/duponin) | - duponin (https://udongein.xyz/users/duponin) | ||||||
| - kalli (https://twitter.com/t0kkuro) | - kalli (https://twitter.com/t0kkuro) | ||||||
|  | - brea (https://refusal.biz/users/tarperfume) | ||||||
|   | |||||||
| @@ -37,7 +37,7 @@ RUN mix do compile, release | |||||||
| 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 libgcc libstdc++ ncurses-libs |     apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs | ||||||
|  |  | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								README.md
									
									
									
									
									
								
							| @@ -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 | ||||||
| @@ -64,7 +64,7 @@ You can use the following environment variables to configure Cannery in | |||||||
| - `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` | ||||||
|   - Available options: `en_US`, `de`, and `fr` |   - Available options: `en_US`, `de`, `fr` and `es` | ||||||
| - `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! | ||||||
| @@ -92,6 +92,15 @@ Cannery is licensed under AGPLv3 or later. A copy of the latest version of the | |||||||
| license can be found at | license can be found at | ||||||
| [LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md). | [LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md). | ||||||
|  |  | ||||||
|  | # Links | ||||||
|  |  | ||||||
|  | - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature | ||||||
|  |   requests and bug reports | ||||||
|  | - [Github](https://github.com/shibaobun/cannery): Source code mirror, please | ||||||
|  |   don't open pull requests to this repository | ||||||
|  | - [Weblate](https://weblate.bubbletea.dev/engage/cannery): Contribute to | ||||||
|  |   translations! | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| [![Build | [![Build | ||||||
|   | |||||||
| @@ -25,12 +25,13 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; | |||||||
|   100% { scale: 1.0; opacity: 1; } |   100% { scale: 1.0; opacity: 1; } | ||||||
| } | } | ||||||
|  |  | ||||||
| .phx-connected > #disconnect, #loading { | // disconnect toast | ||||||
|  | .phx-connected > #disconnect { | ||||||
|   opacity: 0 !important; |   opacity: 0 !important; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| .phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect { | .phx-error > #disconnect { | ||||||
|   opacity: 0.95 !important; |   opacity: 0.95 !important; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -25,7 +25,6 @@ | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   .btn { |   .btn { | ||||||
|     @apply inline-block break-words; |  | ||||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; |     @apply focus:outline-none px-4 py-2 rounded-lg; | ||||||
|     @apply shadow-sm focus:shadow-lg; |     @apply shadow-sm focus:shadow-lg; | ||||||
|     @apply transition-all duration-300 ease-in-out; |     @apply transition-all duration-300 ease-in-out; | ||||||
| @@ -52,7 +51,6 @@ | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   .link { |   .link { | ||||||
|     @apply inline-block break-words; |  | ||||||
|     @apply hover:underline; |     @apply hover:underline; | ||||||
|     @apply transition-colors duration-500 ease-in-out; |     @apply transition-colors duration-500 ease-in-out; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -24,30 +24,24 @@ 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 '../vendor/topbar' | import topbar from 'topbar' | ||||||
| import MaintainAttrs from './maintain_attrs' | import MaintainAttrs from './maintain_attrs' | ||||||
| import ShotLogChart from './shot_log_chart' | import ShotLogChart from './shot_log_chart' | ||||||
| import Alpine from 'alpinejs' | import Date from './date' | ||||||
|  | import DateTime from './datetime' | ||||||
|  |  | ||||||
| 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, { | ||||||
|   dom: { |  | ||||||
|     onBeforeElUpdated (from, to) { |  | ||||||
|       if (from._x_dataStack) { window.Alpine.clone(from, to) } |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   params: { _csrf_token: csrfToken }, |   params: { _csrf_token: csrfToken }, | ||||||
|   hooks: { MaintainAttrs, ShotLogChart } |   hooks: { Date, DateTime, MaintainAttrs, ShotLogChart } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // alpine.js |  | ||||||
| window.Alpine = Alpine |  | ||||||
| Alpine.start() |  | ||||||
|  |  | ||||||
| // Show progress bar on live navigation and form submits | // Show progress bar on live navigation and form submits | ||||||
| topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) | topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) | ||||||
| window.addEventListener('phx:page-loading-start', info => topbar.show()) | window.addEventListener('phx:page-loading-start', info => topbar.show()) | ||||||
| window.addEventListener('phx:page-loading-stop', info => topbar.hide()) | window.addEventListener('phx:page-loading-stop', info => topbar.hide()) | ||||||
|  | window.addEventListener('submit', info => topbar.show()) | ||||||
|  | window.addEventListener('beforeunload', info => topbar.show()) | ||||||
|  |  | ||||||
| // connect if there are any LiveViews on the page | // connect if there are any LiveViews on the page | ||||||
| liveSocket.connect() | liveSocket.connect() | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | export default { | ||||||
|  |   displayDate (el) { | ||||||
|  |     const date = | ||||||
|  |       Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }) | ||||||
|  |         .format(new Date(el.dateTime)) | ||||||
|  |  | ||||||
|  |     el.innerText = date | ||||||
|  |   }, | ||||||
|  |   mounted () { this.displayDate(this.el) }, | ||||||
|  |   updated () { this.displayDate(this.el) } | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | export default { | ||||||
|  |   displayDateTime (el) { | ||||||
|  |     const date = | ||||||
|  |       Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' }) | ||||||
|  |         .format(new Date(el.dateTime)) | ||||||
|  |  | ||||||
|  |     el.innerText = date | ||||||
|  |   }, | ||||||
|  |   mounted () { this.displayDateTime(this.el) }, | ||||||
|  |   updated () { this.displayDateTime(this.el) } | ||||||
|  | } | ||||||
| @@ -11,22 +11,23 @@ export default { | |||||||
|       data: { |       data: { | ||||||
|         datasets: [{ |         datasets: [{ | ||||||
|           label: el.dataset.label, |           label: el.dataset.label, | ||||||
|           data: data.map(({ date, count, labels }) => ({ |           data: data.map(({ date, count, label }) => ({ | ||||||
|             labels, |             label, | ||||||
|             x: date, |             x: date, | ||||||
|             y: count |             y: count | ||||||
|           })), |           })), | ||||||
|           backgroundColor: el.dataset.color, |           backgroundColor: `${el.dataset.color}77`, | ||||||
|           borderColor: el.dataset.color, |           borderColor: el.dataset.color, | ||||||
|           fill: true, |           fill: true, | ||||||
|           borderWidth: 4 |           borderWidth: 3, | ||||||
|  |           pointBorderWidth: 1 | ||||||
|         }] |         }] | ||||||
|       }, |       }, | ||||||
|       options: { |       options: { | ||||||
|         elements: { |         elements: { | ||||||
|           point: { |           point: { | ||||||
|             radius: 7, |             radius: 9, | ||||||
|             hoverRadius: 10 |             hoverRadius: 12 | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|         plugins: { |         plugins: { | ||||||
| @@ -39,7 +40,8 @@ export default { | |||||||
|           tooltip: { |           tooltip: { | ||||||
|             displayColors: false, |             displayColors: false, | ||||||
|             callbacks: { |             callbacks: { | ||||||
|               label: ({ raw: { labels } }) => labels |               title: (contexts) => contexts.map(({ raw: { x } }) => Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }).format(new Date(x))), | ||||||
|  |               label: ({ raw: { label } }) => label | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
| @@ -78,6 +80,27 @@ export default { | |||||||
|       } |       } | ||||||
|     }) |     }) | ||||||
|   }, |   }, | ||||||
|   mounted () { this.initalizeChart(this.el) }, |   updateChart (el) { | ||||||
|   updated () { this.initalizeChart(this.el) } |     const data = JSON.parse(el.dataset.chartData) | ||||||
|  |  | ||||||
|  |     this.el.chart.data = { | ||||||
|  |       datasets: [{ | ||||||
|  |         label: el.dataset.label, | ||||||
|  |         data: data.map(({ date, count, label }) => ({ | ||||||
|  |           label, | ||||||
|  |           x: date, | ||||||
|  |           y: count | ||||||
|  |         })), | ||||||
|  |         backgroundColor: `${el.dataset.color}77`, | ||||||
|  |         borderColor: el.dataset.color, | ||||||
|  |         fill: true, | ||||||
|  |         borderWidth: 3, | ||||||
|  |         pointBorderWidth: 1 | ||||||
|  |       }] | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.el.chart.update() | ||||||
|  |   }, | ||||||
|  |   mounted () { this.initalizeChart(this.el) }, | ||||||
|  |   updated () { this.updateChart(this.el) } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										9316
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9316
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,8 +3,8 @@ | |||||||
|   "description": " ", |   "description": " ", | ||||||
|   "license": "MIT", |   "license": "MIT", | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": "18.12.1", |     "node": "v18.9.1", | ||||||
|     "npm": "8.19.2" |     "npm": "8.19.1" | ||||||
|   }, |   }, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "deploy": "NODE_ENV=production webpack --mode production", |     "deploy": "NODE_ENV=production webpack --mode production", | ||||||
| @@ -13,37 +13,37 @@ | |||||||
|     "test": "standard" |     "test": "standard" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@fortawesome/fontawesome-free": "^6.1.1", |     "@fortawesome/fontawesome-free": "^6.3.0", | ||||||
|     "alpinejs": "^3.10.2", |     "chart.js": "^4.2.1", | ||||||
|     "chart.js": "^3.9.1", |     "chartjs-adapter-date-fns": "^3.0.0", | ||||||
|     "chartjs-adapter-date-fns": "^2.0.0", |  | ||||||
|     "date-fns": "^2.29.3", |     "date-fns": "^2.29.3", | ||||||
|     "phoenix": "file:../deps/phoenix", |     "phoenix": "file:../deps/phoenix", | ||||||
|     "phoenix_html": "file:../deps/phoenix_html", |     "phoenix_html": "file:../deps/phoenix_html", | ||||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", |     "phoenix_live_view": "file:../deps/phoenix_live_view", | ||||||
|     "topbar": "^1.0.1" |     "topbar": "^2.0.1" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@babel/core": "^7.17.10", |     "@babel/core": "^7.21.3", | ||||||
|     "@babel/preset-env": "^7.17.10", |     "@babel/preset-env": "^7.20.2", | ||||||
|     "autoprefixer": "^10.4.7", |     "autoprefixer": "^10.4.14", | ||||||
|     "babel-loader": "^8.2.5", |     "babel-loader": "^9.1.2", | ||||||
|     "copy-webpack-plugin": "^10.2.4", |     "copy-webpack-plugin": "^11.0.0", | ||||||
|     "css-loader": "^6.7.1", |     "css-loader": "^6.7.3", | ||||||
|     "css-minimizer-webpack-plugin": "^3.4.1", |     "css-minimizer-webpack-plugin": "^4.2.2", | ||||||
|     "file-loader": "^6.2.0", |     "file-loader": "^6.2.0", | ||||||
|     "mini-css-extract-plugin": "^2.6.0", |     "mini-css-extract-plugin": "^2.7.5", | ||||||
|     "postcss": "^8.4.13", |     "npm-check-updates": "^16.7.12", | ||||||
|     "postcss-import": "^14.1.0", |     "postcss": "^8.4.21", | ||||||
|     "postcss-loader": "^6.2.1", |     "postcss-import": "^15.1.0", | ||||||
|     "postcss-preset-env": "^7.5.0", |     "postcss-loader": "^7.1.0", | ||||||
|     "sass": "^1.56.0", |     "postcss-preset-env": "^8.0.1", | ||||||
|     "sass-loader": "^12.6.0", |     "sass": "^1.59.3", | ||||||
|  |     "sass-loader": "^13.2.1", | ||||||
|     "standard": "^17.0.0", |     "standard": "^17.0.0", | ||||||
|     "tailwindcss": "^3.0.24", |     "tailwindcss": "^3.2.7", | ||||||
|     "terser-webpack-plugin": "^5.3.1", |     "terser-webpack-plugin": "^5.3.7", | ||||||
|     "webpack": "^5.72.0", |     "webpack": "^5.76.2", | ||||||
|     "webpack-cli": "^4.9.2", |     "webpack-cli": "^5.0.1", | ||||||
|     "webpack-dev-server": "^4.9.0" |     "webpack-dev-server": "^4.13.1" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,157 +0,0 @@ | |||||||
| /** |  | ||||||
|  * @license MIT |  | ||||||
|  * topbar 1.0.0, 2021-01-06 |  | ||||||
|  * https://buunguyen.github.io/topbar |  | ||||||
|  * Copyright (c) 2021 Buu Nguyen |  | ||||||
|  */ |  | ||||||
| (function (window, document) { |  | ||||||
|   "use strict"; |  | ||||||
|  |  | ||||||
|   // https://gist.github.com/paulirish/1579671 |  | ||||||
|   (function () { |  | ||||||
|     var lastTime = 0; |  | ||||||
|     var vendors = ["ms", "moz", "webkit", "o"]; |  | ||||||
|     for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { |  | ||||||
|       window.requestAnimationFrame = |  | ||||||
|         window[vendors[x] + "RequestAnimationFrame"]; |  | ||||||
|       window.cancelAnimationFrame = |  | ||||||
|         window[vendors[x] + "CancelAnimationFrame"] || |  | ||||||
|         window[vendors[x] + "CancelRequestAnimationFrame"]; |  | ||||||
|     } |  | ||||||
|     if (!window.requestAnimationFrame) |  | ||||||
|       window.requestAnimationFrame = function (callback, element) { |  | ||||||
|         var currTime = new Date().getTime(); |  | ||||||
|         var timeToCall = Math.max(0, 16 - (currTime - lastTime)); |  | ||||||
|         var id = window.setTimeout(function () { |  | ||||||
|           callback(currTime + timeToCall); |  | ||||||
|         }, timeToCall); |  | ||||||
|         lastTime = currTime + timeToCall; |  | ||||||
|         return id; |  | ||||||
|       }; |  | ||||||
|     if (!window.cancelAnimationFrame) |  | ||||||
|       window.cancelAnimationFrame = function (id) { |  | ||||||
|         clearTimeout(id); |  | ||||||
|       }; |  | ||||||
|   })(); |  | ||||||
|  |  | ||||||
|   var canvas, |  | ||||||
|     progressTimerId, |  | ||||||
|     fadeTimerId, |  | ||||||
|     currentProgress, |  | ||||||
|     showing, |  | ||||||
|     addEvent = function (elem, type, handler) { |  | ||||||
|       if (elem.addEventListener) elem.addEventListener(type, handler, false); |  | ||||||
|       else if (elem.attachEvent) elem.attachEvent("on" + type, handler); |  | ||||||
|       else elem["on" + type] = handler; |  | ||||||
|     }, |  | ||||||
|     options = { |  | ||||||
|       autoRun: true, |  | ||||||
|       barThickness: 3, |  | ||||||
|       barColors: { |  | ||||||
|         0: "rgba(26,  188, 156, .9)", |  | ||||||
|         ".25": "rgba(52,  152, 219, .9)", |  | ||||||
|         ".50": "rgba(241, 196, 15,  .9)", |  | ||||||
|         ".75": "rgba(230, 126, 34,  .9)", |  | ||||||
|         "1.0": "rgba(211, 84,  0,   .9)", |  | ||||||
|       }, |  | ||||||
|       shadowBlur: 10, |  | ||||||
|       shadowColor: "rgba(0,   0,   0,   .6)", |  | ||||||
|       className: null, |  | ||||||
|     }, |  | ||||||
|     repaint = function () { |  | ||||||
|       canvas.width = window.innerWidth; |  | ||||||
|       canvas.height = options.barThickness * 5; // need space for shadow |  | ||||||
|  |  | ||||||
|       var ctx = canvas.getContext("2d"); |  | ||||||
|       ctx.shadowBlur = options.shadowBlur; |  | ||||||
|       ctx.shadowColor = options.shadowColor; |  | ||||||
|  |  | ||||||
|       var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); |  | ||||||
|       for (var stop in options.barColors) |  | ||||||
|         lineGradient.addColorStop(stop, options.barColors[stop]); |  | ||||||
|       ctx.lineWidth = options.barThickness; |  | ||||||
|       ctx.beginPath(); |  | ||||||
|       ctx.moveTo(0, options.barThickness / 2); |  | ||||||
|       ctx.lineTo( |  | ||||||
|         Math.ceil(currentProgress * canvas.width), |  | ||||||
|         options.barThickness / 2 |  | ||||||
|       ); |  | ||||||
|       ctx.strokeStyle = lineGradient; |  | ||||||
|       ctx.stroke(); |  | ||||||
|     }, |  | ||||||
|     createCanvas = function () { |  | ||||||
|       canvas = document.createElement("canvas"); |  | ||||||
|       var style = canvas.style; |  | ||||||
|       style.position = "fixed"; |  | ||||||
|       style.top = style.left = style.right = style.margin = style.padding = 0; |  | ||||||
|       style.zIndex = 100001; |  | ||||||
|       style.display = "none"; |  | ||||||
|       if (options.className) canvas.classList.add(options.className); |  | ||||||
|       document.body.appendChild(canvas); |  | ||||||
|       addEvent(window, "resize", repaint); |  | ||||||
|     }, |  | ||||||
|     topbar = { |  | ||||||
|       config: function (opts) { |  | ||||||
|         for (var key in opts) |  | ||||||
|           if (options.hasOwnProperty(key)) options[key] = opts[key]; |  | ||||||
|       }, |  | ||||||
|       show: function () { |  | ||||||
|         if (showing) return; |  | ||||||
|         showing = true; |  | ||||||
|         if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); |  | ||||||
|         if (!canvas) createCanvas(); |  | ||||||
|         canvas.style.opacity = 1; |  | ||||||
|         canvas.style.display = "block"; |  | ||||||
|         topbar.progress(0); |  | ||||||
|         if (options.autoRun) { |  | ||||||
|           (function loop() { |  | ||||||
|             progressTimerId = window.requestAnimationFrame(loop); |  | ||||||
|             topbar.progress( |  | ||||||
|               "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) |  | ||||||
|             ); |  | ||||||
|           })(); |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|       progress: function (to) { |  | ||||||
|         if (typeof to === "undefined") return currentProgress; |  | ||||||
|         if (typeof to === "string") { |  | ||||||
|           to = |  | ||||||
|             (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 |  | ||||||
|               ? currentProgress |  | ||||||
|               : 0) + parseFloat(to); |  | ||||||
|         } |  | ||||||
|         currentProgress = to > 1 ? 1 : to; |  | ||||||
|         repaint(); |  | ||||||
|         return currentProgress; |  | ||||||
|       }, |  | ||||||
|       hide: function () { |  | ||||||
|         if (!showing) return; |  | ||||||
|         showing = false; |  | ||||||
|         if (progressTimerId != null) { |  | ||||||
|           window.cancelAnimationFrame(progressTimerId); |  | ||||||
|           progressTimerId = null; |  | ||||||
|         } |  | ||||||
|         (function loop() { |  | ||||||
|           if (topbar.progress("+.1") >= 1) { |  | ||||||
|             canvas.style.opacity -= 0.05; |  | ||||||
|             if (canvas.style.opacity <= 0.05) { |  | ||||||
|               canvas.style.display = "none"; |  | ||||||
|               fadeTimerId = null; |  | ||||||
|               return; |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|           fadeTimerId = window.requestAnimationFrame(loop); |  | ||||||
|         })(); |  | ||||||
|       }, |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|   if (typeof module === "object" && typeof module.exports === "object") { |  | ||||||
|     module.exports = topbar; |  | ||||||
|   } else if (typeof define === "function" && define.amd) { |  | ||||||
|     define(function () { |  | ||||||
|       return topbar; |  | ||||||
|     }); |  | ||||||
|   } else { |  | ||||||
|     this.topbar = topbar; |  | ||||||
|   } |  | ||||||
| }.call(this, window, document)); |  | ||||||
| @@ -45,7 +45,7 @@ module.exports = (env, options) => { | |||||||
|         { |         { | ||||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, |           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, | ||||||
|           type: 'asset/resource', |           type: 'asset/resource', | ||||||
|           generator: { filename: 'fonts/[name][ext]' } |           generator: { filename: 'fonts/[name].[ext]' } | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|     }, |     }, | ||||||
|   | |||||||
| @@ -11,6 +11,8 @@ config :cannery, | |||||||
|   ecto_repos: [Cannery.Repo], |   ecto_repos: [Cannery.Repo], | ||||||
|   generators: [binary_id: true] |   generators: [binary_id: true] | ||||||
|  |  | ||||||
|  | config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||||
|  |  | ||||||
| # Configures the endpoint | # Configures the endpoint | ||||||
| config :cannery, CanneryWeb.Endpoint, | config :cannery, CanneryWeb.Endpoint, | ||||||
|   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], |   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], | ||||||
| @@ -18,8 +20,7 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
|   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: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false], | ||||||
|   pubsub_server: Cannery.PubSub, |   pubsub_server: Cannery.PubSub, | ||||||
|   live_view: [signing_salt: "zOLgd3lr"], |   live_view: [signing_salt: "zOLgd3lr"] | ||||||
|   registration: System.get_env("REGISTRATION") || "invite" |  | ||||||
|  |  | ||||||
| config :cannery, Cannery.Application, automigrate: false | config :cannery, Cannery.Application, automigrate: false | ||||||
|  |  | ||||||
|   | |||||||
| @@ -64,8 +64,9 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
|     ] |     ] | ||||||
|   ] |   ] | ||||||
|  |  | ||||||
| # Do not include metadata nor timestamps in development logs | config :logger, :console, | ||||||
| config :logger, :console, format: "[$level] $message\n" |   format: "[$level] $message $metadata\n\n", | ||||||
|  |   metadata: [:data] | ||||||
|  |  | ||||||
| # Set a higher stacktrace during development. Avoid configuring such | # Set a higher stacktrace during development. Avoid configuring such | ||||||
| # in production as building large stacktraces may be expensive. | # in production as building large stacktraces may be expensive. | ||||||
|   | |||||||
| @@ -15,17 +15,18 @@ end | |||||||
| config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | ||||||
|  |  | ||||||
| # 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") | ||||||
|  |  | ||||||
| maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: [] | maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: [] | ||||||
|  |  | ||||||
| database_url = | database_url = | ||||||
|   if config_env() == :test do |   if config_env() == :test do | ||||||
|     System.get_env("TEST_DATABASE_URL") || |     System.get_env( | ||||||
|  |       "TEST_DATABASE_URL", | ||||||
|       "ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}" |       "ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}" | ||||||
|  |     ) | ||||||
|   else |   else | ||||||
|     System.get_env("DATABASE_URL") || |     System.get_env("DATABASE_URL", "ecto://postgres:postgres@cannery-db/cannery") | ||||||
|       "ecto://postgres:postgres@cannery-db/cannery" |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
| host = | host = | ||||||
| @@ -40,7 +41,7 @@ interface = | |||||||
| config :cannery, Cannery.Repo, | config :cannery, Cannery.Repo, | ||||||
|   # ssl: true, |   # ssl: true, | ||||||
|   url: database_url, |   url: database_url, | ||||||
|   pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), |   pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")), | ||||||
|   socket_options: maybe_ipv6 |   socket_options: maybe_ipv6 | ||||||
|  |  | ||||||
| config :cannery, CanneryWeb.Endpoint, | config :cannery, CanneryWeb.Endpoint, | ||||||
| @@ -49,10 +50,13 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
|     # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html |     # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html | ||||||
|     # for details about using IPv6 vs IPv4 and loopback vs public addresses. |     # for details about using IPv6 vs IPv4 and loopback vs public addresses. | ||||||
|     ip: interface, |     ip: interface, | ||||||
|     port: String.to_integer(System.get_env("PORT") || "4000") |     port: String.to_integer(System.get_env("PORT", "4000")) | ||||||
|   ], |   ], | ||||||
|   server: true, |   server: true | ||||||
|   registration: System.get_env("REGISTRATION") || "invite" |  | ||||||
|  | if config_env() in [:dev, :prod] do | ||||||
|  |   config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||||
|  | end | ||||||
|  |  | ||||||
| if config_env() == :prod do | if config_env() == :prod do | ||||||
|   # The secret key base is used to sign/encrypt cookies and other secrets. |   # The secret key base is used to sign/encrypt cookies and other secrets. | ||||||
| @@ -76,12 +80,12 @@ if config_env() == :prod do | |||||||
|   config :cannery, Cannery.Mailer, |   config :cannery, Cannery.Mailer, | ||||||
|     adapter: Swoosh.Adapters.SMTP, |     adapter: Swoosh.Adapters.SMTP, | ||||||
|     relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), |     relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), | ||||||
|     port: System.get_env("SMTP_PORT") || 587, |     port: System.get_env("SMTP_PORT", "587"), | ||||||
|     username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), |     username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), | ||||||
|     password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), |     password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), | ||||||
|     ssl: System.get_env("SMTP_SSL") == "true", |     ssl: System.get_env("SMTP_SSL") == "true", | ||||||
|     email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}", |     email_from: System.get_env("EMAIL_FROM", "no-reply@#{System.get_env("HOST")}"), | ||||||
|     email_name: System.get_env("EMAIL_NAME") || "Cannery" |     email_name: System.get_env("EMAIL_NAME", "Cannery") | ||||||
|  |  | ||||||
|   # ## Using releases |   # ## Using releases | ||||||
|   # |   # | ||||||
|   | |||||||
| @@ -22,6 +22,9 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
| # 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 | ||||||
|  |  | ||||||
|  | # Don't require invites for signups | ||||||
|  | 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: :warn | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |   import Ecto.Query, warn: false | ||||||
|   alias Cannery.{Mailer, Repo} |   alias Cannery.{Mailer, Repo} | ||||||
|   alias Cannery.Accounts.{User, UserToken} |   alias Cannery.Accounts.{Invite, Invites, User, UserToken} | ||||||
|   alias Ecto.{Changeset, Multi} |   alias Ecto.{Changeset, Multi} | ||||||
|   alias Oban.Job |   alias Oban.Job | ||||||
|  |  | ||||||
| @@ -23,22 +23,24 @@ defmodule Cannery.Accounts do | |||||||
|       nil |       nil | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_user_by_email(String.t()) :: User.t() | nil |   @spec get_user_by_email(email :: String.t()) :: User.t() | nil | ||||||
|   def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) |   def get_user_by_email(email) when is_binary(email) do | ||||||
|  |     Repo.get_by(User, email: email) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets a user by email and password. |   Gets a user by email and password. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_user_by_email_and_password("foo@example.com", "correct_password") |       iex> get_user_by_email_and_password("foo@example.com", "valid_password") | ||||||
|       %User{} |       %User{} | ||||||
|  |  | ||||||
|       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") |       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||||
|       nil |       nil | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_user_by_email_and_password(String.t(), String.t()) :: |   @spec get_user_by_email_and_password(email :: String.t(), password :: String.t()) :: | ||||||
|           User.t() | nil |           User.t() | nil | ||||||
|   def get_user_by_email_and_password(email, password) |   def get_user_by_email_and_password(email, password) | ||||||
|       when is_binary(email) and is_binary(password) do |       when is_binary(email) and is_binary(password) do | ||||||
| @@ -53,28 +55,30 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_user!(123) |       iex> get_user!(user_id) | ||||||
|       %User{} |       user | ||||||
|  |  | ||||||
|       iex> get_user!(456) |       iex> get_user!() | ||||||
|       ** (Ecto.NoResultsError) |       ** (Ecto.NoResultsError) | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_user!(User.t()) :: User.t() |   @spec get_user!(User.id()) :: User.t() | ||||||
|   def get_user!(id), do: Repo.get!(User, id) |   def get_user!(id) do | ||||||
|  |     Repo.get!(User, id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns all users grouped by role. |   Returns all users grouped by role. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) |       iex> list_all_users_by_role(user1) | ||||||
|       [admin: [%User{}], user: [%User{}, %User{}]] |       %{admin: [%User{role: :admin}], user: [%User{role: :user}]} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} |   @spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]} | ||||||
|   def list_all_users_by_role(%User{role: :admin}) do |   def list_all_users_by_role(%User{role: :admin}) do | ||||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) |     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -82,13 +86,12 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) |       iex> list_users_by_role(:admin) | ||||||
|       [%User{}] |       [%User{role: :admin}] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_users_by_role(:admin | :user) :: [User.t()] |   @spec list_users_by_role(:admin) :: [User.t()] | ||||||
|   def list_users_by_role(role) do |   def list_users_by_role(:admin = role) do | ||||||
|     role = role |> to_string() |  | ||||||
|     Repo.all(from u in User, where: u.role == ^role, order_by: u.email) |     Repo.all(from u in User, where: u.role == ^role, order_by: u.email) | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -99,22 +102,38 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> register_user(%{field: value}) |       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||||
|       {:ok, %User{}} |       {:ok, %User{email: "foo@example.com"}} | ||||||
|  |  | ||||||
|       iex> register_user(%{field: bad_value}) |       iex> register_user(%{email: "foo@example"}) | ||||||
|       {:error, %Changeset{}} |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())} |   @spec register_user(attrs :: map()) :: | ||||||
|   def register_user(attrs) do |           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||||
|  |   @spec register_user(attrs :: map(), Invite.token() | nil) :: | ||||||
|  |           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||||
|  |   def register_user(attrs, invite_token \\ nil) do | ||||||
|  |     Multi.new() | ||||||
|  |     |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true)) | ||||||
|  |     |> Multi.run(:use_invite, fn _changes_so_far, _repo -> | ||||||
|  |       if allow_registration?() and invite_token |> is_nil() do | ||||||
|  |         {:ok, nil} | ||||||
|  |       else | ||||||
|  |         Invites.use_invite(invite_token) | ||||||
|  |       end | ||||||
|  |     end) | ||||||
|  |     |> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} -> | ||||||
|       # if no registered users, make first user an admin |       # if no registered users, make first user an admin | ||||||
|     role = |       role = if count == 0, do: :admin, else: :user | ||||||
|       if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0, |       User.registration_changeset(attrs, invite) |> User.role_changeset(role) | ||||||
|         do: "admin", |     end) | ||||||
|         else: "user" |     |> Repo.transaction() | ||||||
|  |     |> case do | ||||||
|     %User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert() |       {:ok, %{add_user: user}} -> {:ok, user} | ||||||
|  |       {:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||||
|  |       {:error, :add_user, changeset, _changes_so_far} -> {:error, changeset} | ||||||
|  |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -122,16 +141,18 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> change_user_registration(user) |       iex> change_user_registration() | ||||||
|       %Changeset{data: %User{}} |       %Changeset{} | ||||||
|  |  | ||||||
|  |       iex> change_user_registration(%{password: "hi"} | ||||||
|  |       %Changeset{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec change_user_registration(User.t() | User.new_user()) :: |   @spec change_user_registration() :: User.changeset() | ||||||
|           Changeset.t(User.t() | User.new_user()) |   @spec change_user_registration(attrs :: map()) :: User.changeset() | ||||||
|   @spec change_user_registration(User.t() | User.new_user(), map()) :: |   def change_user_registration(attrs \\ %{}) do | ||||||
|           Changeset.t(User.t() | User.new_user()) |     User.registration_changeset(attrs, nil, hash_password: false) | ||||||
|   def change_user_registration(user, attrs \\ %{}), |   end | ||||||
|     do: User.registration_changeset(user, attrs, hash_password: false) |  | ||||||
|  |  | ||||||
|   ## Settings |   ## Settings | ||||||
|  |  | ||||||
| @@ -140,24 +161,29 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> change_user_email(user) |       iex> change_user_email(%User{email: "foo@example.com"}) | ||||||
|       %Changeset{data: %User{}} |       %Changeset{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec change_user_email(User.t(), map()) :: Changeset.t(User.t()) |   @spec change_user_email(User.t()) :: User.changeset() | ||||||
|   def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) |   @spec change_user_email(User.t(), attrs :: map()) :: User.changeset() | ||||||
|  |   def change_user_email(user, attrs \\ %{}) do | ||||||
|  |     User.email_changeset(user, attrs) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns an `%Changeset{}` for changing the user role. |   Returns an `%Changeset{}` for changing the user role. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> change_user_role(user) |       iex> change_user_role(%User{}, :user) | ||||||
|       %Changeset{data: %User{}} |       %Changeset{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec change_user_role(User.t(), atom()) :: Changeset.t(User.t()) |   @spec change_user_role(User.t(), User.role()) :: User.changeset() | ||||||
|   def change_user_role(user, role), do: User.role_changeset(user, role) |   def change_user_role(user, role) do | ||||||
|  |     User.role_changeset(user, role) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Emulates that the email will change without actually changing |   Emulates that the email will change without actually changing | ||||||
| @@ -165,15 +191,15 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> apply_user_email(user, "valid password", %{email: ...}) |       iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"}) | ||||||
|       {:ok, %User{}} |       {:ok, %User{}} | ||||||
|  |  | ||||||
|       iex> apply_user_email(user, "invalid password", %{email: ...}) |       iex> apply_user_email(user, "invalid password", %{email: "new_email@account"}) | ||||||
|       {:error, %Changeset{}} |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec apply_user_email(User.t(), String.t(), map()) :: |   @spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) :: | ||||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} |           {:ok, User.t()} | {:error, User.changeset()} | ||||||
|   def apply_user_email(user, password, attrs) do |   def apply_user_email(user, password, attrs) do | ||||||
|     user |     user | ||||||
|     |> User.email_changeset(attrs) |     |> User.email_changeset(attrs) | ||||||
| @@ -187,7 +213,7 @@ defmodule Cannery.Accounts do | |||||||
|   If the token matches, the user email is updated and the token is deleted. |   If the token matches, the user email is updated and the token is deleted. | ||||||
|   The confirmed_at date is also updated to the current time. |   The confirmed_at date is also updated to the current time. | ||||||
|   """ |   """ | ||||||
|   @spec update_user_email(User.t(), String.t()) :: :ok | :error |   @spec update_user_email(User.t(), token :: String.t()) :: :ok | :error | ||||||
|   def update_user_email(user, token) do |   def update_user_email(user, token) do | ||||||
|     context = "change:#{user.email}" |     context = "change:#{user.email}" | ||||||
|  |  | ||||||
| @@ -200,7 +226,7 @@ defmodule Cannery.Accounts do | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t() |   @spec user_email_multi(User.t(), email :: String.t(), context :: String.t()) :: Multi.t() | ||||||
|   defp user_email_multi(user, email, context) do |   defp user_email_multi(user, email, context) do | ||||||
|     changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() |     changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() | ||||||
|  |  | ||||||
| @@ -214,11 +240,12 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) |       iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end) | ||||||
|       {:ok, %{to: ..., body: ...}} |       %Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t() |   @spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: | ||||||
|  |           Job.t() | ||||||
|   def deliver_update_email_instructions(user, current_email, update_email_url_fun) |   def deliver_update_email_instructions(user, current_email, update_email_url_fun) | ||||||
|       when is_function(update_email_url_fun, 1) do |       when is_function(update_email_url_fun, 1) do | ||||||
|     {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") |     {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") | ||||||
| @@ -231,28 +258,32 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> change_user_password(user) |       iex> change_user_password(%User{}) | ||||||
|       %Changeset{data: %User{}} |       %Changeset{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec change_user_password(User.t(), map()) :: Changeset.t(User.t()) |   @spec change_user_password(User.t(), attrs :: map()) :: User.changeset() | ||||||
|   def change_user_password(user, attrs \\ %{}), |   def change_user_password(user, attrs \\ %{}) do | ||||||
|     do: User.password_changeset(user, attrs, hash_password: false) |     User.password_changeset(user, attrs, hash_password: false) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Updates the user password. |   Updates the user password. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> update_user_password(user, "valid password", %{password: ...}) |       iex> reset_user_password(user, %{ | ||||||
|  |       ...>   password: "new password", | ||||||
|  |       ...>   password_confirmation: "new password" | ||||||
|  |       ...> }) | ||||||
|       {:ok, %User{}} |       {:ok, %User{}} | ||||||
|  |  | ||||||
|       iex> update_user_password(user, "invalid password", %{password: ...}) |       iex> update_user_password(user, "invalid password", %{password: "123"}) | ||||||
|       {:error, %Changeset{}} |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec update_user_password(User.t(), String.t(), map()) :: |   @spec update_user_password(User.t(), String.t(), attrs :: map()) :: | ||||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} |           {:ok, User.t()} | {:error, User.changeset()} | ||||||
|   def update_user_password(user, password, attrs) do |   def update_user_password(user, password, attrs) do | ||||||
|     changeset = |     changeset = | ||||||
|       user |       user | ||||||
| @@ -270,49 +301,54 @@ defmodule Cannery.Accounts do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns an `%Changeset{}` for changing the user locale. |   Returns an `Ecto.Changeset.t()` for changing the user locale. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> change_user_locale(user) |       iex> change_user_locale(%User{}) | ||||||
|       %Changeset{data: %User{}} |       %Changeset{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec change_user_locale(User.t()) :: Changeset.t(User.t()) |   @spec change_user_locale(User.t()) :: User.changeset() | ||||||
|   def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) |   def change_user_locale(%{locale: locale} = user) do | ||||||
|  |     User.locale_changeset(user, locale) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Updates the user locale. |   Updates the user locale. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> update_user_locale(user, "valid locale") |       iex> update_user_locale(user, "en_US") | ||||||
|       {:ok, %User{}} |       {:ok, %User{}} | ||||||
|  |  | ||||||
|       iex> update_user_password(user, "invalid locale") |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: |   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} |           {:ok, User.t()} | {:error, User.changeset()} | ||||||
|   def update_user_locale(user, locale), |   def update_user_locale(user, locale) do | ||||||
|     do: user |> User.locale_changeset(locale) |> Repo.update() |     user |> User.locale_changeset(locale) |> Repo.update() | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Deletes a user. must be performed by an admin or the same user! |   Deletes a user. must be performed by an admin or the same user! | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) |       iex> delete_user!(user, %User{id: 123, role: :admin}) | ||||||
|       %User{} |       %User{} | ||||||
|  |  | ||||||
|       iex> delete_user!(%User{id: 123}, %User{id: 123}) |       iex> delete_user!(user, user) | ||||||
|       %User{} |       %User{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec delete_user!(User.t(), User.t()) :: User.t() |   @spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() | ||||||
|   def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!() |   def delete_user!(user, %User{role: :admin}) do | ||||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!() |     user |> Repo.delete!() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do | ||||||
|  |     user |> Repo.delete!() | ||||||
|  |   end | ||||||
|  |  | ||||||
|   ## Session |   ## Session | ||||||
|  |  | ||||||
| @@ -329,7 +365,7 @@ defmodule Cannery.Accounts do | |||||||
|   @doc """ |   @doc """ | ||||||
|   Gets the user with the given signed token. |   Gets the user with the given signed token. | ||||||
|   """ |   """ | ||||||
|   @spec get_user_by_session_token(String.t()) :: User.t() |   @spec get_user_by_session_token(token :: String.t()) :: User.t() | ||||||
|   def get_user_by_session_token(token) do |   def get_user_by_session_token(token) do | ||||||
|     {:ok, query} = UserToken.verify_session_token_query(token) |     {:ok, query} = UserToken.verify_session_token_query(token) | ||||||
|     Repo.one(query) |     Repo.one(query) | ||||||
| @@ -338,9 +374,9 @@ 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(String.t()) :: :ok |   @spec delete_session_token(token :: String.t()) :: :ok | ||||||
|   def delete_session_token(token) do |   def delete_session_token(token) do | ||||||
|     Repo.delete_all(UserToken.token_and_context_query(token, "session")) |     UserToken.token_and_context_query(token, "session") |> Repo.delete_all() | ||||||
|     :ok |     :ok | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -349,19 +385,53 @@ defmodule Cannery.Accounts do | |||||||
|   """ |   """ | ||||||
|   @spec allow_registration?() :: boolean() |   @spec allow_registration?() :: boolean() | ||||||
|   def allow_registration? do |   def allow_registration? do | ||||||
|     Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or |     registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?() | ||||||
|       list_users_by_role(:admin) |> Enum.empty?() |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns an atom representing the current configured registration mode | ||||||
|  |   """ | ||||||
|  |   @spec registration_mode() :: :public | :invite_only | ||||||
|  |   def registration_mode do | ||||||
|  |     case Application.get_env(:cannery, Cannery.Accounts)[:registration] do | ||||||
|  |       "public" -> :public | ||||||
|  |       _other -> :invite_only | ||||||
|  |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Checks if user is an admin |   Checks if user is an admin | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> is_admin?(%User{role: :admin}) | ||||||
|  |       true | ||||||
|  |  | ||||||
|  |       iex> is_admin?(%User{}) | ||||||
|  |       false | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec is_admin?(User.t()) :: boolean() |   @spec is_admin?(User.t()) :: boolean() | ||||||
|   def is_admin?(%User{id: user_id}) do |   def is_admin?(%User{id: user_id}) do | ||||||
|     Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin) |     Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) | ||||||
|     |> is_nil() |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Checks to see if user has the admin role | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> is_already_admin?(%User{role: :admin}) | ||||||
|  |       true | ||||||
|  |  | ||||||
|  |       iex> is_already_admin?(%User{}) | ||||||
|  |       false | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec is_already_admin?(User.t() | nil) :: boolean() | ||||||
|  |   def is_already_admin?(%User{role: :admin}), do: true | ||||||
|  |   def is_already_admin?(_invalid_user), do: false | ||||||
|  |  | ||||||
|   ## Confirmation |   ## Confirmation | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -369,10 +439,10 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) |       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||||
|       {:ok, %{to: ..., body: ...}} |       %Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||||
|  |  | ||||||
|       iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) |       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||||
|       {:error, :already_confirmed} |       {:error, :already_confirmed} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
| @@ -394,7 +464,7 @@ defmodule Cannery.Accounts do | |||||||
|   If the token matches, the user account is marked as confirmed |   If the token matches, the user account is marked as confirmed | ||||||
|   and the token is deleted. |   and the token is deleted. | ||||||
|   """ |   """ | ||||||
|   @spec confirm_user(String.t()) :: {:ok, User.t()} | atom() |   @spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error | ||||||
|   def confirm_user(token) do |   def confirm_user(token) do | ||||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), |     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), | ||||||
|          %User{} = user <- Repo.one(query), |          %User{} = user <- Repo.one(query), | ||||||
| @@ -419,8 +489,8 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) |       iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end) | ||||||
|       {:ok, %{to: ..., body: ...}} |       %Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() |   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() | ||||||
| @@ -436,14 +506,14 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_user_by_reset_password_token("validtoken") |       iex> get_user_by_reset_password_token(encoded_token) | ||||||
|       %User{} |       %User{} | ||||||
|  |  | ||||||
|       iex> get_user_by_reset_password_token("invalidtoken") |       iex> get_user_by_reset_password_token("invalidtoken") | ||||||
|       nil |       nil | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil |   @spec get_user_by_reset_password_token(token :: String.t()) :: User.t() | nil | ||||||
|   def get_user_by_reset_password_token(token) do |   def get_user_by_reset_password_token(token) do | ||||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), |     with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), | ||||||
|          %User{} = user <- Repo.one(query) do |          %User{} = user <- Repo.one(query) do | ||||||
| @@ -458,14 +528,18 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) |       iex> reset_user_password(user, %{ | ||||||
|  |       ...>   password: "new password", | ||||||
|  |       ...>   password_confirmation: "new password" | ||||||
|  |       ...> }) | ||||||
|       {:ok, %User{}} |       {:ok, %User{}} | ||||||
|  |  | ||||||
|       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) |       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||||
|       {:error, %Changeset{}} |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())} |   @spec reset_user_password(User.t(), attrs :: map()) :: | ||||||
|  |           {:ok, User.t()} | {:error, User.changeset()} | ||||||
|   def reset_user_password(user, attrs) do |   def reset_user_password(user, attrs) do | ||||||
|     Multi.new() |     Multi.new() | ||||||
|     |> Multi.update(:user, User.password_changeset(user, attrs)) |     |> Multi.update(:user, User.password_changeset(user, attrs)) | ||||||
|   | |||||||
| @@ -27,21 +27,21 @@ defmodule Cannery.Email do | |||||||
|   @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() |   @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() | ||||||
|   def generate_email("welcome", user, %{"url" => url}) do |   def generate_email("welcome", user, %{"url" => url}) do | ||||||
|     user |     user | ||||||
|     |> base_email(dgettext("emails", "Confirm your %{name} account", name: "Cannery")) |     |> base_email(dgettext("emails", "Confirm your Cannery account")) | ||||||
|     |> render_body("confirm_email.html", %{user: user, url: url}) |     |> render_body("confirm_email.html", %{user: user, url: url}) | ||||||
|     |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) |     |> text_body(EmailView.render("confirm_email.txt", %{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 %{name} password", name: "Cannery")) |     |> base_email(dgettext("emails", "Reset your Cannery password")) | ||||||
|     |> render_body("reset_password.html", %{user: user, url: url}) |     |> render_body("reset_password.html", %{user: user, url: url}) | ||||||
|     |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) |     |> text_body(EmailView.render("reset_password.txt", %{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 %{name} email", name: "Cannery")) |     |> base_email(dgettext("emails", "Update your Cannery email")) | ||||||
|     |> render_body("update_email.html", %{user: user, url: url}) |     |> render_body("update_email.html", %{user: user, url: url}) | ||||||
|     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) |     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| defmodule Cannery.Invites.Invite do | defmodule Cannery.Accounts.Invite do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   An invite, created by an admin to allow someone to join their instance. An |   An invite, created by an admin to allow someone to join their instance. An | ||||||
|   invite can be enabled or disabled, and can have an optional number of uses if |   invite can be enabled or disabled, and can have an optional number of uses if | ||||||
| @@ -7,8 +7,8 @@ defmodule Cannery.Invites.Invite do | |||||||
| 
 | 
 | ||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|   alias Ecto.{Changeset, UUID} |   alias Cannery.Accounts.User | ||||||
|   alias Cannery.{Accounts.User, Invites.Invite} |   alias Ecto.{Association, Changeset, UUID} | ||||||
| 
 | 
 | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |   @primary_key {:id, :binary_id, autogenerate: true} | ||||||
|   @foreign_key_type :binary_id |   @foreign_key_type :binary_id | ||||||
| @@ -18,42 +18,48 @@ defmodule Cannery.Invites.Invite do | |||||||
|     field :uses_left, :integer, default: nil |     field :uses_left, :integer, default: nil | ||||||
|     field :disabled_at, :naive_datetime |     field :disabled_at, :naive_datetime | ||||||
| 
 | 
 | ||||||
|     belongs_to :user, User |     belongs_to :created_by, User | ||||||
|  | 
 | ||||||
|  |     has_many :users, User | ||||||
| 
 | 
 | ||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @type t :: %Invite{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           name: String.t(), |           name: String.t(), | ||||||
|           token: String.t(), |           token: token(), | ||||||
|           uses_left: integer() | nil, |           uses_left: integer() | nil, | ||||||
|           disabled_at: NaiveDateTime.t(), |           disabled_at: NaiveDateTime.t(), | ||||||
|           user: User.t(), |           created_by: User.t() | nil | Association.NotLoaded.t(), | ||||||
|           user_id: User.id(), |           created_by_id: User.id() | nil, | ||||||
|  |           users: [User.t()] | Association.NotLoaded.t(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_invite :: %Invite{} |   @type new_invite :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type changeset :: Changeset.t(t() | new_invite()) | ||||||
|  |   @type token :: String.t() | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec create_changeset(new_invite(), User.t(), token :: binary(), attrs :: map()) :: |   @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset() | ||||||
|           Changeset.t(new_invite()) |   def create_changeset(%User{id: user_id}, token, attrs) do | ||||||
|   def create_changeset(invite, %User{id: user_id}, token, attrs) do |     %__MODULE__{} | ||||||
|     invite |     |> change(token: token, created_by_id: user_id) | ||||||
|     |> change(token: token, user_id: user_id) |  | ||||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) |     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||||
|     |> validate_required([:name, :token, :user_id]) |     |> validate_length(:name, max: 255) | ||||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) |     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||||
|  |     |> validate_required([:name, :token, :created_by_id]) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite()) |   @spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset() | ||||||
|   def update_changeset(invite, attrs) do |   def update_changeset(invite, attrs) do | ||||||
|     invite |     invite | ||||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) |     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||||
|     |> validate_required([:name]) |     |> validate_length(:name, max: 255) | ||||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) |     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||||
|  |     |> validate_required([:name]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
							
								
								
									
										208
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | |||||||
|  | defmodule Cannery.Accounts.Invites do | ||||||
|  |   @moduledoc """ | ||||||
|  |   The Invites context. | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   import Ecto.Query, warn: false | ||||||
|  |   alias Ecto.Multi | ||||||
|  |   alias Cannery.Accounts.{Invite, User} | ||||||
|  |   alias Cannery.Repo | ||||||
|  |  | ||||||
|  |   @invite_token_length 20 | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the list of invites. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> list_invites(%User{id: 123, role: :admin}) | ||||||
|  |       [%Invite{}, ...] | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec list_invites(User.t()) :: [Invite.t()] | ||||||
|  |   def list_invites(%User{role: :admin}) do | ||||||
|  |     Repo.all(from i in Invite, order_by: i.name) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets a single invite for a user | ||||||
|  |  | ||||||
|  |   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||||
|  |       %Invite{} | ||||||
|  |  | ||||||
|  |       > get_invite!(456, %User{id: 123, role: :admin}) | ||||||
|  |       ** (Ecto.NoResultsError) | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||||
|  |   def get_invite!(id, %User{role: :admin}) do | ||||||
|  |     Repo.get!(Invite, id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns if an invite token is still valid | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> valid_invite_token?("valid_token") | ||||||
|  |       %Invite{} | ||||||
|  |  | ||||||
|  |       iex> valid_invite_token?("invalid_token") | ||||||
|  |       nil | ||||||
|  |   """ | ||||||
|  |   @spec valid_invite_token?(Invite.token() | nil) :: boolean() | ||||||
|  |   def valid_invite_token?(token) when token in [nil, ""], do: false | ||||||
|  |  | ||||||
|  |   def valid_invite_token?(token) do | ||||||
|  |     Repo.exists?( | ||||||
|  |       from i in Invite, | ||||||
|  |         where: i.token == ^token, | ||||||
|  |         where: i.disabled_at |> is_nil() | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||||
|  |   completely used. | ||||||
|  |   """ | ||||||
|  |   @spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||||
|  |   def use_invite(invite_token) do | ||||||
|  |     Multi.new() | ||||||
|  |     |> Multi.run(:invite, fn _changes_so_far, _repo -> | ||||||
|  |       invite_token |> get_invite_by_token() | ||||||
|  |     end) | ||||||
|  |     |> Multi.update(:decrement_invite, fn %{invite: invite} -> | ||||||
|  |       decrement_invite_changeset(invite) | ||||||
|  |     end) | ||||||
|  |     |> Repo.transaction() | ||||||
|  |     |> case do | ||||||
|  |       {:ok, %{decrement_invite: invite}} -> {:ok, invite} | ||||||
|  |       {:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||||
|  |   defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token} | ||||||
|  |  | ||||||
|  |   defp get_invite_by_token(token) do | ||||||
|  |     Repo.one( | ||||||
|  |       from i in Invite, | ||||||
|  |         where: i.token == ^token, | ||||||
|  |         where: i.disabled_at |> is_nil() | ||||||
|  |     ) | ||||||
|  |     |> case do | ||||||
|  |       nil -> {:error, :invalid_token} | ||||||
|  |       invite -> {:ok, invite} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | nil | ||||||
|  |   def get_use_count(%Invite{id: invite_id} = invite, user) do | ||||||
|  |     [invite] |> get_use_counts(user) |> Map.get(invite_id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_use_counts([Invite.t()], User.t()) :: | ||||||
|  |           %{optional(Invite.id()) => non_neg_integer()} | ||||||
|  |   def get_use_counts(invites, %User{role: :admin}) do | ||||||
|  |     invite_ids = invites |> Enum.map(fn %{id: invite_id} -> invite_id end) | ||||||
|  |  | ||||||
|  |     Repo.all( | ||||||
|  |       from u in User, | ||||||
|  |         where: u.invite_id in ^invite_ids, | ||||||
|  |         group_by: u.invite_id, | ||||||
|  |         select: {u.invite_id, count(u.id)} | ||||||
|  |     ) | ||||||
|  |     |> Map.new() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec decrement_invite_changeset(Invite.t()) :: Invite.changeset() | ||||||
|  |   defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do | ||||||
|  |     invite |> Invite.update_changeset(%{}) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do | ||||||
|  |     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||||
|  |     invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do | ||||||
|  |     invite |> Invite.update_changeset(%{uses_left: uses_left - 1}) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Creates a invite. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||||
|  |       {:ok, %Invite{}} | ||||||
|  |  | ||||||
|  |       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec create_invite(User.t(), attrs :: map()) :: | ||||||
|  |           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||||
|  |   def create_invite(%User{role: :admin} = user, attrs) do | ||||||
|  |     token = | ||||||
|  |       :crypto.strong_rand_bytes(@invite_token_length) | ||||||
|  |       |> Base.url_encode64() | ||||||
|  |       |> binary_part(0, @invite_token_length) | ||||||
|  |  | ||||||
|  |     Invite.create_changeset(user, token, attrs) |> Repo.insert() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Updates a invite. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||||
|  |       {:ok, %Invite{}} | ||||||
|  |  | ||||||
|  |       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||||
|  |           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||||
|  |   def update_invite(invite, attrs, %User{role: :admin}) do | ||||||
|  |     invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Deletes a invite. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||||
|  |       {:ok, %Invite{}} | ||||||
|  |  | ||||||
|  |       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec delete_invite(Invite.t(), User.t()) :: | ||||||
|  |           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||||
|  |   def delete_invite(invite, %User{role: :admin}) do | ||||||
|  |     invite |> Repo.delete() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Deletes a invite. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||||
|  |       %Invite{} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||||
|  |   def delete_invite!(invite, %User{role: :admin}) do | ||||||
|  |     invite |> Repo.delete!() | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,13 +1,13 @@ | |||||||
| defmodule Cannery.Accounts.User do | defmodule Cannery.Accounts.User do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   A cannery user |   A Cannery user | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|   import CanneryWeb.Gettext |   import CanneryWeb.Gettext | ||||||
|   alias Ecto.{Changeset, UUID} |   alias Ecto.{Association, Changeset, UUID} | ||||||
|   alias Cannery.{Accounts.User, Invites.Invite} |   alias Cannery.Accounts.{Invite, User} | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -15,7 +15,9 @@ defmodule Cannery.Accounts.User do | |||||||
|              :email, |              :email, | ||||||
|              :confirmed_at, |              :confirmed_at, | ||||||
|              :role, |              :role, | ||||||
|              :locale |              :locale, | ||||||
|  |              :inserted_at, | ||||||
|  |              :updated_at | ||||||
|            ]} |            ]} | ||||||
|   @derive {Inspect, except: [:password]} |   @derive {Inspect, except: [:password]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |   @primary_key {:id, :binary_id, autogenerate: true} | ||||||
| @@ -28,7 +30,9 @@ defmodule Cannery.Accounts.User do | |||||||
|     field :role, Ecto.Enum, values: [:admin, :user], default: :user |     field :role, Ecto.Enum, values: [:admin, :user], default: :user | ||||||
|     field :locale, :string |     field :locale, :string | ||||||
|  |  | ||||||
|     has_many :invites, Invite, on_delete: :delete_all |     has_many :created_invites, Invite, foreign_key: :created_by_id | ||||||
|  |  | ||||||
|  |     belongs_to :invite, Invite | ||||||
|  |  | ||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
| @@ -39,14 +43,18 @@ defmodule Cannery.Accounts.User do | |||||||
|           password: String.t(), |           password: String.t(), | ||||||
|           hashed_password: String.t(), |           hashed_password: String.t(), | ||||||
|           confirmed_at: NaiveDateTime.t(), |           confirmed_at: NaiveDateTime.t(), | ||||||
|           role: atom(), |           role: role(), | ||||||
|           locale: String.t() | nil, |           locale: String.t() | nil, | ||||||
|           invites: [Invite.t()], |           created_invites: [Invite.t()] | Association.NotLoaded.t(), | ||||||
|  |           invite: Invite.t() | nil | Association.NotLoaded.t(), | ||||||
|  |           invite_id: Invite.id() | nil, | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_user :: %User{} |   @type new_user :: %User{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type changeset :: Changeset.t(t() | new_user()) | ||||||
|  |   @type role :: :admin | :user | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   A user changeset for registration. |   A user changeset for registration. | ||||||
| @@ -65,26 +73,26 @@ defmodule Cannery.Accounts.User do | |||||||
|       validations on a LiveView form), this option can be set to `false`. |       validations on a LiveView form), this option can be set to `false`. | ||||||
|       Defaults to `true`. |       Defaults to `true`. | ||||||
|   """ |   """ | ||||||
|   @spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user()) |   @spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset() | ||||||
|   @spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) :: |   @spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset() | ||||||
|           Changeset.t(t() | new_user()) |   def registration_changeset(attrs, invite, opts \\ []) do | ||||||
|   def registration_changeset(user, attrs, opts \\ []) do |     %User{} | ||||||
|     user |     |> cast(attrs, [:email, :password, :locale]) | ||||||
|     |> cast(attrs, [:email, :password, :role, :locale]) |     |> put_change(:invite_id, if(invite, do: invite.id)) | ||||||
|  |     |> validate_length(:locale, max: 255) | ||||||
|     |> validate_email() |     |> validate_email() | ||||||
|     |> validate_password(opts) |     |> validate_password(opts) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   A user changeset for role. |   A user changeset for role. | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec role_changeset(t(), role :: atom()) :: Changeset.t(t()) |   @spec role_changeset(t() | new_user() | changeset(), role()) :: changeset() | ||||||
|   def role_changeset(user, role) do |   def role_changeset(user, role) do | ||||||
|     user |> cast(%{"role" => role}, [:role]) |     user |> change(role: role) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user()) |   @spec validate_email(changeset()) :: changeset() | ||||||
|   defp validate_email(changeset) do |   defp validate_email(changeset) do | ||||||
|     changeset |     changeset | ||||||
|     |> validate_required([:email]) |     |> validate_required([:email]) | ||||||
| @@ -96,8 +104,8 @@ defmodule Cannery.Accounts.User do | |||||||
|     |> unique_constraint(:email) |     |> unique_constraint(:email) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) :: |   @spec validate_password(changeset(), opts :: keyword()) :: | ||||||
|           Changeset.t(t() | new_user()) |           changeset() | ||||||
|   defp validate_password(changeset, opts) do |   defp validate_password(changeset, opts) do | ||||||
|     changeset |     changeset | ||||||
|     |> validate_required([:password]) |     |> validate_required([:password]) | ||||||
| @@ -108,8 +116,7 @@ defmodule Cannery.Accounts.User do | |||||||
|     |> maybe_hash_password(opts) |     |> maybe_hash_password(opts) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) :: |   @spec maybe_hash_password(changeset(), opts :: keyword()) :: changeset() | ||||||
|           Changeset.t(t() | new_user()) |  | ||||||
|   defp maybe_hash_password(changeset, opts) do |   defp maybe_hash_password(changeset, opts) do | ||||||
|     hash_password? = Keyword.get(opts, :hash_password, true) |     hash_password? = Keyword.get(opts, :hash_password, true) | ||||||
|     password = get_change(changeset, :password) |     password = get_change(changeset, :password) | ||||||
| @@ -128,7 +135,7 @@ defmodule Cannery.Accounts.User do | |||||||
|  |  | ||||||
|   It requires the email to change otherwise an error is added. |   It requires the email to change otherwise an error is added. | ||||||
|   """ |   """ | ||||||
|   @spec email_changeset(t(), attrs :: map()) :: Changeset.t(t()) |   @spec email_changeset(t(), attrs :: map()) :: changeset() | ||||||
|   def email_changeset(user, attrs) do |   def email_changeset(user, attrs) do | ||||||
|     user |     user | ||||||
|     |> cast(attrs, [:email]) |     |> cast(attrs, [:email]) | ||||||
| @@ -151,8 +158,8 @@ defmodule Cannery.Accounts.User do | |||||||
|       validations on a LiveView form), this option can be set to `false`. |       validations on a LiveView form), this option can be set to `false`. | ||||||
|       Defaults to `true`. |       Defaults to `true`. | ||||||
|   """ |   """ | ||||||
|   @spec password_changeset(t(), attrs :: map()) :: Changeset.t(t()) |   @spec password_changeset(t(), attrs :: map()) :: changeset() | ||||||
|   @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t()) |   @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: changeset() | ||||||
|   def password_changeset(user, attrs, opts \\ []) do |   def password_changeset(user, attrs, opts \\ []) do | ||||||
|     user |     user | ||||||
|     |> cast(attrs, [:password]) |     |> cast(attrs, [:password]) | ||||||
| @@ -163,7 +170,7 @@ defmodule Cannery.Accounts.User do | |||||||
|   @doc """ |   @doc """ | ||||||
|   Confirms the account by setting `confirmed_at`. |   Confirms the account by setting `confirmed_at`. | ||||||
|   """ |   """ | ||||||
|   @spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t()) |   @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 = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||||
|     user_or_changeset |> change(confirmed_at: now) |     user_or_changeset |> change(confirmed_at: now) | ||||||
| @@ -189,7 +196,7 @@ defmodule Cannery.Accounts.User do | |||||||
|   @doc """ |   @doc """ | ||||||
|   Validates the current password otherwise adds an error to the changeset. |   Validates the current password otherwise adds an error to the changeset. | ||||||
|   """ |   """ | ||||||
|   @spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t()) |   @spec validate_current_password(changeset(), String.t()) :: changeset() | ||||||
|   def validate_current_password(changeset, password) do |   def validate_current_password(changeset, password) do | ||||||
|     if valid_password?(changeset.data, password), |     if valid_password?(changeset.data, password), | ||||||
|       do: changeset, |       do: changeset, | ||||||
| @@ -199,10 +206,11 @@ defmodule Cannery.Accounts.User do | |||||||
|   @doc """ |   @doc """ | ||||||
|   A changeset for changing the user's locale |   A changeset for changing the user's locale | ||||||
|   """ |   """ | ||||||
|   @spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t()) |   @spec locale_changeset(t() | changeset(), locale :: String.t() | nil) :: changeset() | ||||||
|   def locale_changeset(user_or_changeset, locale) do |   def locale_changeset(user_or_changeset, locale) do | ||||||
|     user_or_changeset |     user_or_changeset | ||||||
|     |> cast(%{"locale" => locale}, [:locale]) |     |> cast(%{"locale" => locale}, [:locale]) | ||||||
|  |     |> validate_length(:locale, max: 255) | ||||||
|     |> validate_required(:locale) |     |> validate_required(:locale) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| defmodule Cannery.Accounts.UserToken do | defmodule Cannery.Accounts.UserToken do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   Schema for serialized user session and authentication tokens |   Schema for a user's session token | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Query |   import Ecto.Query | ||||||
|   alias Ecto.{Query, UUID} |   alias Cannery.Accounts.User | ||||||
|   alias Cannery.{Accounts.User, Accounts.UserToken} |   alias Ecto.{Association, UUID} | ||||||
|  |  | ||||||
|   @hash_algorithm :sha256 |   @hash_algorithm :sha256 | ||||||
|   @rand_size 32 |   @rand_size 32 | ||||||
| @@ -30,27 +30,27 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|     timestamps(updated_at: false) |     timestamps(updated_at: false) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %UserToken{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           token: String.t(), |           token: token(), | ||||||
|           context: String.t(), |           context: String.t(), | ||||||
|           sent_to: String.t(), |           sent_to: String.t(), | ||||||
|           user: User.t(), |           user: User.t() | Association.NotLoaded.t(), | ||||||
|           user_id: User.id(), |           user_id: User.id() | nil, | ||||||
|           inserted_at: NaiveDateTime.t() |           inserted_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_token :: %UserToken{} |   @type new_user_token :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type token :: binary() | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Generates a token that will be stored in a signed place, |   Generates a token that will be stored in a signed place, | ||||||
|   such as session or cookie. As they are signed, those |   such as session or cookie. As they are signed, those | ||||||
|   tokens do not need to be hashed. |   tokens do not need to be hashed. | ||||||
|   """ |   """ | ||||||
|   @spec build_session_token(User.t()) :: {token :: String.t(), new_token()} |   def build_session_token(user) do | ||||||
|   def build_session_token(%{id: user_id}) do |  | ||||||
|     token = :crypto.strong_rand_bytes(@rand_size) |     token = :crypto.strong_rand_bytes(@rand_size) | ||||||
|     {token, %UserToken{token: token, context: "session", user_id: user_id}} |     {token, %__MODULE__{token: token, context: "session", user_id: user.id}} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -58,7 +58,6 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|  |  | ||||||
|   The query returns the user found by the token. |   The query returns the user found by the token. | ||||||
|   """ |   """ | ||||||
|   @spec verify_session_token_query(token :: String.t()) :: {:ok, Query.t()} |  | ||||||
|   def verify_session_token_query(token) do |   def verify_session_token_query(token) do | ||||||
|     query = |     query = | ||||||
|       from token in token_and_context_query(token, "session"), |       from token in token_and_context_query(token, "session"), | ||||||
| @@ -77,19 +76,16 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|   The token is valid for a week as long as users don't change |   The token is valid for a week as long as users don't change | ||||||
|   their email. |   their email. | ||||||
|   """ |   """ | ||||||
|   @spec build_email_token(User.t(), context :: String.t()) :: {token :: String.t(), new_token()} |  | ||||||
|   def build_email_token(user, context) do |   def build_email_token(user, context) do | ||||||
|     build_hashed_token(user, context, user.email) |     build_hashed_token(user, context, user.email) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec build_hashed_token(User.t(), String.t(), String.t()) :: |  | ||||||
|           {String.t(), new_token()} |  | ||||||
|   defp build_hashed_token(user, context, sent_to) do |   defp build_hashed_token(user, context, sent_to) do | ||||||
|     token = :crypto.strong_rand_bytes(@rand_size) |     token = :crypto.strong_rand_bytes(@rand_size) | ||||||
|     hashed_token = :crypto.hash(@hash_algorithm, token) |     hashed_token = :crypto.hash(@hash_algorithm, token) | ||||||
|  |  | ||||||
|     {Base.url_encode64(token, padding: false), |     {Base.url_encode64(token, padding: false), | ||||||
|      %UserToken{ |      %__MODULE__{ | ||||||
|        token: hashed_token, |        token: hashed_token, | ||||||
|        context: context, |        context: context, | ||||||
|        sent_to: sent_to, |        sent_to: sent_to, | ||||||
| @@ -102,8 +98,6 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|  |  | ||||||
|   The query returns the user found by the token. |   The query returns the user found by the token. | ||||||
|   """ |   """ | ||||||
|   @spec verify_email_token_query(token :: String.t(), context :: String.t()) :: |  | ||||||
|           {:ok, Query.t()} | :error |  | ||||||
|   def verify_email_token_query(token, context) do |   def verify_email_token_query(token, context) do | ||||||
|     case Base.url_decode64(token, padding: false) do |     case Base.url_decode64(token, padding: false) do | ||||||
|       {:ok, decoded_token} -> |       {:ok, decoded_token} -> | ||||||
| @@ -123,7 +117,6 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec days_for_context(context :: <<_::56>>) :: non_neg_integer() |  | ||||||
|   defp days_for_context("confirm"), do: @confirm_validity_in_days |   defp days_for_context("confirm"), do: @confirm_validity_in_days | ||||||
|   defp days_for_context("reset_password"), do: @reset_password_validity_in_days |   defp days_for_context("reset_password"), do: @reset_password_validity_in_days | ||||||
|  |  | ||||||
| @@ -132,8 +125,6 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|  |  | ||||||
|   The query returns the user token record. |   The query returns the user token record. | ||||||
|   """ |   """ | ||||||
|   @spec verify_change_email_token_query(token :: String.t(), context :: String.t()) :: |  | ||||||
|           {:ok, Query.t()} | :error |  | ||||||
|   def verify_change_email_token_query(token, context) do |   def verify_change_email_token_query(token, context) do | ||||||
|     case Base.url_decode64(token, padding: false) do |     case Base.url_decode64(token, padding: false) do | ||||||
|       {:ok, decoded_token} -> |       {:ok, decoded_token} -> | ||||||
| @@ -153,21 +144,18 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the given token with the given context. |   Returns the given token with the given context. | ||||||
|   """ |   """ | ||||||
|   @spec token_and_context_query(token :: String.t(), context :: String.t()) :: Query.t() |  | ||||||
|   def token_and_context_query(token, context) do |   def token_and_context_query(token, context) do | ||||||
|     from UserToken, where: [token: ^token, context: ^context] |     from __MODULE__, where: [token: ^token, context: ^context] | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets all tokens for the given user for the given contexts. |   Gets all tokens for the given user for the given contexts. | ||||||
|   """ |   """ | ||||||
|   @spec user_and_contexts_query(User.t(), contexts :: :all | nonempty_maybe_improper_list()) :: |   def user_and_contexts_query(user, :all) do | ||||||
|           Query.t() |     from t in __MODULE__, where: t.user_id == ^user.id | ||||||
|   def user_and_contexts_query(%{id: user_id}, :all) do |  | ||||||
|     from t in UserToken, where: t.user_id == ^user_id |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def user_and_contexts_query(%{id: user_id}, [_ | _] = contexts) do |   def user_and_contexts_query(user, [_ | _] = contexts) do | ||||||
|     from t in UserToken, 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 | ||||||
|   | |||||||
| @@ -4,41 +4,143 @@ defmodule Cannery.ActivityLog do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |   import Ecto.Query, warn: false | ||||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} |   alias Cannery.Ammo.{Pack, Type} | ||||||
|   alias Ecto.{Changeset, Multi} |   alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo} | ||||||
|  |   alias Ecto.{Multi, Queryable} | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the list of shot_groups. |   Returns the list of shot_records. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> list_shot_groups(%User{id: 123}) |       iex> list_shot_records(:all, %User{id: 123}) | ||||||
|       [%ShotGroup{}, ...] |       [%ShotRecord{}, ...] | ||||||
|  |  | ||||||
|  |       iex> list_shot_records("cool", :all, %User{id: 123}) | ||||||
|  |       [%ShotRecord{notes: "My cool shot record"}, ...] | ||||||
|  |  | ||||||
|  |       iex> list_shot_records("cool", :rifle, %User{id: 123}) | ||||||
|  |       [%ShotRecord{notes: "Shot some rifle rounds"}, ...] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_shot_groups(User.t()) :: [ShotGroup.t()] |   @spec list_shot_records(Type.class() | :all, User.t()) :: [ShotRecord.t()] | ||||||
|   def list_shot_groups(%User{id: user_id}) do |   @spec list_shot_records(search :: nil | String.t(), Type.class() | :all, User.t()) :: | ||||||
|     Repo.all(from(sg in ShotGroup, where: sg.user_id == ^user_id)) |           [ShotRecord.t()] | ||||||
|  |   def list_shot_records(search \\ nil, type, %{id: user_id}) do | ||||||
|  |     from(sg in ShotRecord, | ||||||
|  |       as: :sg, | ||||||
|  |       left_join: ag in Pack, | ||||||
|  |       as: :ag, | ||||||
|  |       on: sg.pack_id == ag.id, | ||||||
|  |       left_join: at in Type, | ||||||
|  |       as: :at, | ||||||
|  |       on: ag.type_id == at.id, | ||||||
|  |       where: sg.user_id == ^user_id, | ||||||
|  |       distinct: sg.id | ||||||
|  |     ) | ||||||
|  |     |> list_shot_records_search(search) | ||||||
|  |     |> list_shot_records_filter_type(type) | ||||||
|  |     |> Repo.all() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) :: | ||||||
|  |           Queryable.t() | ||||||
|  |   defp list_shot_records_search(query, search) when search in ["", nil], do: query | ||||||
|  |  | ||||||
|  |   defp list_shot_records_search(query, search) when search |> is_binary() do | ||||||
|  |     trimmed_search = String.trim(search) | ||||||
|  |  | ||||||
|  |     query | ||||||
|  |     |> where( | ||||||
|  |       [sg: sg, ag: ag, at: at], | ||||||
|  |       fragment( | ||||||
|  |         "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |         sg.search, | ||||||
|  |         ^trimmed_search | ||||||
|  |       ) or | ||||||
|  |         fragment( | ||||||
|  |           "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |           ag.search, | ||||||
|  |           ^trimmed_search | ||||||
|  |         ) or | ||||||
|  |         fragment( | ||||||
|  |           "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |           at.search, | ||||||
|  |           ^trimmed_search | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     |> order_by([sg: sg], { | ||||||
|  |       :desc, | ||||||
|  |       fragment( | ||||||
|  |         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||||
|  |         sg.search, | ||||||
|  |         ^trimmed_search | ||||||
|  |       ) | ||||||
|  |     }) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec list_shot_records_filter_type(Queryable.t(), Type.class() | :all) :: | ||||||
|  |           Queryable.t() | ||||||
|  |   defp list_shot_records_filter_type(query, :rifle), | ||||||
|  |     do: query |> where([at: at], at.class == :rifle) | ||||||
|  |  | ||||||
|  |   defp list_shot_records_filter_type(query, :pistol), | ||||||
|  |     do: query |> where([at: at], at.class == :pistol) | ||||||
|  |  | ||||||
|  |   defp list_shot_records_filter_type(query, :shotgun), | ||||||
|  |     do: query |> where([at: at], at.class == :shotgun) | ||||||
|  |  | ||||||
|  |   defp list_shot_records_filter_type(query, _all), do: query | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns a count of shot records. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_shot_record_count!(%User{id: 123}) | ||||||
|  |       3 | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec get_shot_record_count!(User.t()) :: integer() | ||||||
|  |   def get_shot_record_count!(%User{id: user_id}) do | ||||||
|  |     Repo.one( | ||||||
|  |       from sg in ShotRecord, | ||||||
|  |         where: sg.user_id == ^user_id, | ||||||
|  |         select: count(sg.id), | ||||||
|  |         distinct: true | ||||||
|  |     ) || 0 | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec list_shot_records_for_pack(Pack.t(), User.t()) :: [ShotRecord.t()] | ||||||
|  |   def list_shot_records_for_pack( | ||||||
|  |         %Pack{id: pack_id, user_id: user_id}, | ||||||
|  |         %User{id: user_id} | ||||||
|  |       ) do | ||||||
|  |     Repo.all( | ||||||
|  |       from sg in ShotRecord, | ||||||
|  |         where: sg.pack_id == ^pack_id, | ||||||
|  |         where: sg.user_id == ^user_id | ||||||
|  |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets a single shot_group. |   Gets a single shot_record. | ||||||
|  |  | ||||||
|   Raises `Ecto.NoResultsError` if the Shot group does not exist. |   Raises `Ecto.NoResultsError` if the shot record does not exist. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_shot_group!(123, %User{id: 123}) |       iex> get_shot_record!(123, %User{id: 123}) | ||||||
|       %ShotGroup{} |       %ShotRecord{} | ||||||
|  |  | ||||||
|       iex> get_shot_group!(456, %User{id: 123}) |       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 sg in ShotRecord, | ||||||
|         where: sg.id == ^id, |         where: sg.id == ^id, | ||||||
|         where: sg.user_id == ^user_id, |         where: sg.user_id == ^user_id, | ||||||
|         order_by: sg.date |         order_by: sg.date | ||||||
| @@ -46,138 +148,250 @@ defmodule Cannery.ActivityLog do | |||||||
|   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, Changeset.t(ShotGroup.t()) | 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}} -> | ||||||
|         {:ok, |         pack = | ||||||
|          repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)} |           Repo.one( | ||||||
|  |             from ag in Pack, | ||||||
|  |               where: ag.id == ^pack_id, | ||||||
|  |               where: ag.user_id == ^user_id | ||||||
|  |           ) | ||||||
|  |  | ||||||
|  |         {: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, Changeset.t(ShotGroup.t()) | 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 ag in Pack, where: ag.id == ^pack_id and ag.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, Changeset.t(ShotGroup.t())} |           {: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 ag in Pack, where: ag.id == ^pack_id and ag.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 """ | ||||||
|  |   Returns the number of shot rounds for a pack | ||||||
|  |   """ | ||||||
|  |   @spec get_used_count(Pack.t(), User.t()) :: non_neg_integer() | ||||||
|  |   def get_used_count(%Pack{id: pack_id} = pack, user) do | ||||||
|  |     [pack] | ||||||
|  |     |> get_used_counts(user) | ||||||
|  |     |> Map.get(pack_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the number of shot rounds for multiple packs | ||||||
|  |   """ | ||||||
|  |   @spec get_used_counts([Pack.t()], User.t()) :: | ||||||
|  |           %{optional(Pack.id()) => non_neg_integer()} | ||||||
|  |   def get_used_counts(packs, %User{id: user_id}) do | ||||||
|  |     pack_ids = | ||||||
|  |       packs | ||||||
|  |       |> Enum.map(fn %{id: pack_id} -> pack_id end) | ||||||
|  |  | ||||||
|  |     Repo.all( | ||||||
|  |       from sg in ShotRecord, | ||||||
|  |         where: sg.pack_id in ^pack_ids, | ||||||
|  |         where: sg.user_id == ^user_id, | ||||||
|  |         group_by: sg.pack_id, | ||||||
|  |         select: {sg.pack_id, sum(sg.count)} | ||||||
|  |     ) | ||||||
|  |     |> Map.new() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the last entered shot record date for a pack | ||||||
|  |   """ | ||||||
|  |   @spec get_last_used_date(Pack.t(), User.t()) :: Date.t() | nil | ||||||
|  |   def get_last_used_date(%Pack{id: pack_id} = pack, user) do | ||||||
|  |     [pack] | ||||||
|  |     |> get_last_used_dates(user) | ||||||
|  |     |> Map.get(pack_id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the last entered shot record date for a pack | ||||||
|  |   """ | ||||||
|  |   @spec get_last_used_dates([Pack.t()], User.t()) :: %{optional(Pack.id()) => Date.t()} | ||||||
|  |   def get_last_used_dates(packs, %User{id: user_id}) do | ||||||
|  |     pack_ids = | ||||||
|  |       packs | ||||||
|  |       |> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end) | ||||||
|  |  | ||||||
|  |     Repo.all( | ||||||
|  |       from sg in ShotRecord, | ||||||
|  |         where: sg.pack_id in ^pack_ids, | ||||||
|  |         where: sg.user_id == ^user_id, | ||||||
|  |         group_by: sg.pack_id, | ||||||
|  |         select: {sg.pack_id, max(sg.date)} | ||||||
|  |     ) | ||||||
|  |     |> Map.new() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets the total number of rounds shot for a type | ||||||
|  |  | ||||||
|  |   Raises `Ecto.NoResultsError` if the type does not exist. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_used_count_for_type(123, %User{id: 123}) | ||||||
|  |       35 | ||||||
|  |  | ||||||
|  |       iex> get_used_count_for_type(456, %User{id: 123}) | ||||||
|  |       ** (Ecto.NoResultsError) | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec get_used_count_for_type(Type.t(), User.t()) :: non_neg_integer() | ||||||
|  |   def get_used_count_for_type(%Type{id: type_id} = type, user) do | ||||||
|  |     [type] | ||||||
|  |     |> get_used_count_for_types(user) | ||||||
|  |     |> Map.get(type_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets the total number of rounds shot for multiple types | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_used_count_for_types(123, %User{id: 123}) | ||||||
|  |       35 | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec get_used_count_for_types([Type.t()], User.t()) :: | ||||||
|  |           %{optional(Type.id()) => non_neg_integer()} | ||||||
|  |   def get_used_count_for_types(types, %User{id: user_id}) do | ||||||
|  |     type_ids = | ||||||
|  |       types | ||||||
|  |       |> Enum.map(fn %Type{id: type_id, user_id: ^user_id} -> type_id end) | ||||||
|  |  | ||||||
|  |     Repo.all( | ||||||
|  |       from ag in Pack, | ||||||
|  |         left_join: sg in ShotRecord, | ||||||
|  |         on: ag.id == sg.pack_id, | ||||||
|  |         where: ag.type_id in ^type_ids, | ||||||
|  |         where: not (sg.count |> is_nil()), | ||||||
|  |         group_by: ag.type_id, | ||||||
|  |         select: {ag.type_id, sum(sg.count)} | ||||||
|  |     ) | ||||||
|  |     |> Map.new() | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -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, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} |  | ||||||
|   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 |  | ||||||
|  |  | ||||||
|     belongs_to :user, User |  | ||||||
|     belongs_to :ammo_group, AmmoGroup |  | ||||||
|  |  | ||||||
|     timestamps() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @type t :: %ShotGroup{ |  | ||||||
|           id: id(), |  | ||||||
|           count: integer, |  | ||||||
|           notes: String.t() | nil, |  | ||||||
|           date: Date.t() | nil, |  | ||||||
|           ammo_group: AmmoGroup.t() | nil, |  | ||||||
|           ammo_group_id: AmmoGroup.id(), |  | ||||||
|           user: User.t() | nil, |  | ||||||
|           user_id: User.id(), |  | ||||||
|           inserted_at: NaiveDateTime.t(), |  | ||||||
|           updated_at: NaiveDateTime.t() |  | ||||||
|         } |  | ||||||
|   @type new_shot_group :: %ShotGroup{} |  | ||||||
|   @type id :: UUID.t() |  | ||||||
|  |  | ||||||
|   @doc false |  | ||||||
|   @spec create_changeset( |  | ||||||
|           new_shot_group(), |  | ||||||
|           User.t() | any(), |  | ||||||
|           AmmoGroup.t() | any(), |  | ||||||
|           attrs :: map() |  | ||||||
|         ) :: |  | ||||||
|           Changeset.t(new_shot_group()) |  | ||||||
|   def create_changeset( |  | ||||||
|         shot_group, |  | ||||||
|         %User{id: user_id}, |  | ||||||
|         %AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group, |  | ||||||
|         attrs |  | ||||||
|       ) |  | ||||||
|       when not (user_id |> is_nil()) and not (ammo_group_id |> is_nil()) do |  | ||||||
|     shot_group |  | ||||||
|     |> change(user_id: user_id) |  | ||||||
|     |> change(ammo_group_id: ammo_group_id) |  | ||||||
|     |> cast(attrs, [:count, :notes, :date]) |  | ||||||
|     |> validate_number(:count, greater_than: 0) |  | ||||||
|     |> validate_create_shot_group_count(ammo_group) |  | ||||||
|     |> validate_required([:count, :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_number(:count, greater_than: 0) |  | ||||||
|     |> validate_required([:count, :ammo_group_id, :user_id]) |  | ||||||
|     |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo group")) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do |  | ||||||
|     if changeset |> Changeset.get_field(:count) > ammo_group_count do |  | ||||||
|       error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) |  | ||||||
|       changeset |> Changeset.add_error(:count, error) |  | ||||||
|     else |  | ||||||
|       changeset |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc false |  | ||||||
|   @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: |  | ||||||
|           Changeset.t(t() | new_shot_group()) |  | ||||||
|   def update_changeset( |  | ||||||
|         %ShotGroup{user_id: user_id} = shot_group, |  | ||||||
|         %User{id: user_id} = user, |  | ||||||
|         attrs |  | ||||||
|       ) |  | ||||||
|       when not (user_id |> is_nil()) do |  | ||||||
|     shot_group |  | ||||||
|     |> cast(attrs, [:count, :notes, :date]) |  | ||||||
|     |> 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, |  | ||||||
|          %ShotGroup{count: count} = shot_group, |  | ||||||
|          %User{id: user_id} |  | ||||||
|        ) |  | ||||||
|        when not (user_id |> is_nil()) do |  | ||||||
|     %{ammo_group: %AmmoGroup{count: ammo_group_count, user_id: ^user_id}} = |  | ||||||
|       shot_group |> Repo.preload(:ammo_group) |  | ||||||
|  |  | ||||||
|     new_shot_group_count = changeset |> Changeset.get_field(:count) |  | ||||||
|     shot_diff_to_add = new_shot_group_count - count |  | ||||||
|  |  | ||||||
|     cond do |  | ||||||
|       shot_diff_to_add > ammo_group_count -> |  | ||||||
|         error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) |  | ||||||
|         changeset |> Changeset.add_error(:count, error) |  | ||||||
|  |  | ||||||
|       new_shot_group_count <= 0 -> |  | ||||||
|         changeset |> Changeset.add_error(:count, dgettext("errors", "Count must be at least 1")) |  | ||||||
|  |  | ||||||
|       true -> |  | ||||||
|         changeset |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										126
									
								
								lib/cannery/activity_log/shot_record.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								lib/cannery/activity_log/shot_record.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | |||||||
|  | defmodule Cannery.ActivityLog.ShotRecord do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A shot record 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.Pack} | ||||||
|  |   alias Ecto.{Changeset, UUID} | ||||||
|  |  | ||||||
|  |   @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() | ||||||
|  |   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: NaiveDateTime.t(), | ||||||
|  |           updated_at: NaiveDateTime.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 | ||||||
							
								
								
									
										1521
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
							
						
						
									
										1521
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,129 +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.{AmmoGroup, AmmoType} |  | ||||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, 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 |  | ||||||
|  |  | ||||||
|     belongs_to :ammo_type, AmmoType |  | ||||||
|     belongs_to :container, Container |  | ||||||
|     belongs_to :user, User |  | ||||||
|  |  | ||||||
|     has_many :shot_groups, ShotGroup |  | ||||||
|  |  | ||||||
|     timestamps() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @type t :: %AmmoGroup{ |  | ||||||
|           id: id(), |  | ||||||
|           count: integer, |  | ||||||
|           notes: String.t() | nil, |  | ||||||
|           price_paid: float() | nil, |  | ||||||
|           staged: boolean(), |  | ||||||
|           ammo_type: AmmoType.t() | nil, |  | ||||||
|           ammo_type_id: AmmoType.id(), |  | ||||||
|           container: Container.t() | nil, |  | ||||||
|           container_id: Container.id(), |  | ||||||
|           user: User.t() | nil, |  | ||||||
|           user_id: User.id(), |  | ||||||
|           inserted_at: NaiveDateTime.t(), |  | ||||||
|           updated_at: NaiveDateTime.t() |  | ||||||
|         } |  | ||||||
|   @type new_ammo_group :: %AmmoGroup{} |  | ||||||
|   @type id :: UUID.t() |  | ||||||
|  |  | ||||||
|   @doc false |  | ||||||
|   @spec create_changeset( |  | ||||||
|           new_ammo_group(), |  | ||||||
|           AmmoType.t() | nil, |  | ||||||
|           Container.t() | nil, |  | ||||||
|           User.t(), |  | ||||||
|           attrs :: map() |  | ||||||
|         ) :: Changeset.t(new_ammo_group()) |  | ||||||
|   def create_changeset( |  | ||||||
|         ammo_group, |  | ||||||
|         %AmmoType{id: ammo_type_id}, |  | ||||||
|         %Container{id: container_id, user_id: user_id}, |  | ||||||
|         %User{id: user_id}, |  | ||||||
|         attrs |  | ||||||
|       ) |  | ||||||
|       when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and |  | ||||||
|              not (user_id |> is_nil()) 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]) |  | ||||||
|     |> validate_number(:count, greater_than: 0) |  | ||||||
|     |> validate_required([:count, :staged, :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.t(t() | new_ammo_group()) |  | ||||||
|   def update_changeset(ammo_group, attrs, user) do |  | ||||||
|     ammo_group |  | ||||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :container_id]) |  | ||||||
|     |> validate_number(:count, greater_than_or_equal_to: 0) |  | ||||||
|     |> validate_container_id(user) |  | ||||||
|     |> validate_required([:count, :staged, :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.t(t() | new_ammo_group()) |  | ||||||
|   def range_changeset(ammo_group, attrs) do |  | ||||||
|     ammo_group |  | ||||||
|     |> cast(attrs, [:count, :staged]) |  | ||||||
|     |> validate_required([:count, :staged]) |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										143
									
								
								lib/cannery/ammo/pack.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								lib/cannery/ammo/pack.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | |||||||
|  | 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 Ecto.Schema | ||||||
|  |   import CanneryWeb.Gettext | ||||||
|  |   import Ecto.Changeset | ||||||
|  |   alias Cannery.Ammo.Type | ||||||
|  |   alias Cannery.{Accounts.User, Containers, Containers.Container} | ||||||
|  |   alias Ecto.{Changeset, UUID} | ||||||
|  |  | ||||||
|  |   @derive {Jason.Encoder, | ||||||
|  |            only: [ | ||||||
|  |              :id, | ||||||
|  |              :count, | ||||||
|  |              :notes, | ||||||
|  |              :price_paid, | ||||||
|  |              :lot_number, | ||||||
|  |              :staged, | ||||||
|  |              :type_id, | ||||||
|  |              :container_id | ||||||
|  |            ]} | ||||||
|  |   @primary_key {:id, :binary_id, autogenerate: true} | ||||||
|  |   @foreign_key_type :binary_id | ||||||
|  |   schema "packs" do | ||||||
|  |     field :count, :integer | ||||||
|  |     field :notes, :string | ||||||
|  |     field :price_paid, :float | ||||||
|  |     field :staged, :boolean, default: false | ||||||
|  |     field :lot_number, :string | ||||||
|  |     field :purchased_on, :date | ||||||
|  |  | ||||||
|  |     belongs_to :type, Type | ||||||
|  |     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(), | ||||||
|  |           lot_number: String.t() | nil, | ||||||
|  |           purchased_on: Date.t(), | ||||||
|  |           type: Type.t() | nil, | ||||||
|  |           type_id: Type.id(), | ||||||
|  |           container_id: Container.id(), | ||||||
|  |           user_id: User.id(), | ||||||
|  |           inserted_at: NaiveDateTime.t(), | ||||||
|  |           updated_at: NaiveDateTime.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{id: type_id}, | ||||||
|  |         %Container{id: container_id, user_id: user_id}, | ||||||
|  |         %User{id: user_id}, | ||||||
|  |         attrs | ||||||
|  |       ) | ||||||
|  |       when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do | ||||||
|  |     pack | ||||||
|  |     |> change(type_id: type_id) | ||||||
|  |     |> change(user_id: user_id) | ||||||
|  |     |> change(container_id: container_id) | ||||||
|  |     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number]) | ||||||
|  |     |> validate_number(:count, greater_than: 0) | ||||||
|  |     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||||
|  |     |> validate_length(:lot_number, max: 255) | ||||||
|  |     |> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Invalid changeset, used to prompt user to select type and container | ||||||
|  |   """ | ||||||
|  |   def create_changeset(pack, _invalid_type, _invalid_container, _invalid_user, attrs) do | ||||||
|  |     pack | ||||||
|  |     |> cast(attrs, [:type_id, :container_id]) | ||||||
|  |     |> validate_required([:type_id, :container_id]) | ||||||
|  |     |> validate_number(:count, greater_than: 0) | ||||||
|  |     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||||
|  |     |> validate_length(:lot_number, max: 255) | ||||||
|  |     |> add_error(:invalid, dgettext("errors", "Please select a type and container")) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc false | ||||||
|  |   @spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset() | ||||||
|  |   def update_changeset(pack, attrs, user) do | ||||||
|  |     pack | ||||||
|  |     |> cast(attrs, [ | ||||||
|  |       :count, | ||||||
|  |       :price_paid, | ||||||
|  |       :notes, | ||||||
|  |       :staged, | ||||||
|  |       :purchased_on, | ||||||
|  |       :lot_number, | ||||||
|  |       :container_id | ||||||
|  |     ]) | ||||||
|  |     |> 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([: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" 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, :staged]) | ||||||
|  |     |> validate_required([:count, :staged]) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| defmodule Cannery.Ammo.AmmoType do | defmodule Cannery.Ammo.Type do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   An ammunition type. |   An ammunition type. | ||||||
| 
 | 
 | ||||||
| @@ -8,143 +8,227 @@ defmodule Cannery.Ammo.AmmoType do | |||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|   alias Cannery.Accounts.User |   alias Cannery.Accounts.User | ||||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} |   alias Cannery.Ammo.Pack | ||||||
|   alias Ecto.{Changeset, UUID} |   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} |   @primary_key {:id, :binary_id, autogenerate: true} | ||||||
|   @foreign_key_type :binary_id |   @foreign_key_type :binary_id | ||||||
|   schema "ammo_types" do |   schema "types" do | ||||||
|     field :name, :string |     field :name, :string | ||||||
|     field :desc, :string |     field :desc, :string | ||||||
| 
 | 
 | ||||||
|     # https://en.wikipedia.org/wiki/Bullet#Abbreviations |     field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol] | ||||||
|  | 
 | ||||||
|  |     # common fields | ||||||
|  |     # https://shootersreference.com/reloadingdata/bullet_abbreviations/ | ||||||
|     field :bullet_type, :string |     field :bullet_type, :string | ||||||
|     field :bullet_core, :string |     field :bullet_core, :string | ||||||
|     field :cartridge, :string |     # also gauge for shotguns | ||||||
|     field :caliber, :string |     field :caliber, :string | ||||||
|     field :case_material, :string |     field :case_material, :string | ||||||
|     field :jacket_type, :string |  | ||||||
|     field :muzzle_velocity, :integer |  | ||||||
|     field :powder_type, :string |     field :powder_type, :string | ||||||
|     field :powder_grains_per_charge, :integer |  | ||||||
|     field :grains, :integer |     field :grains, :integer | ||||||
|     field :pressure, :string |     field :pressure, :string | ||||||
|     field :primer_type, :string |     field :primer_type, :string | ||||||
|     field :firing_type, :string |     field :firing_type, :string | ||||||
|  |     field :manufacturer, :string | ||||||
|  |     field :upc, :string | ||||||
|  | 
 | ||||||
|     field :tracer, :boolean, default: false |     field :tracer, :boolean, default: false | ||||||
|     field :incendiary, :boolean, default: false |     field :incendiary, :boolean, default: false | ||||||
|     field :blank, :boolean, default: false |     field :blank, :boolean, default: false | ||||||
|     field :corrosive, :boolean, default: false |     field :corrosive, :boolean, default: false | ||||||
| 
 | 
 | ||||||
|     field :manufacturer, :string |     # rifle/pistol fields | ||||||
|     field :upc, :string |     field :cartridge, :string | ||||||
|  |     field :jacket_type, :string | ||||||
|  |     field :powder_grains_per_charge, :integer | ||||||
|  |     field :muzzle_velocity, :integer | ||||||
| 
 | 
 | ||||||
|     belongs_to :user, User |     # shotgun fields | ||||||
|  |     field :wadding, :string | ||||||
|  |     field :shot_type, :string | ||||||
|  |     field :shot_material, :string | ||||||
|  |     field :shot_size, :string | ||||||
|  |     field :unfired_length, :string | ||||||
|  |     field :brass_height, :string | ||||||
|  |     field :chamber_size, :string | ||||||
|  |     field :load_grains, :integer | ||||||
|  |     field :shot_charge_weight, :string | ||||||
|  |     field :dram_equivalent, :string | ||||||
| 
 | 
 | ||||||
|     has_many :ammo_groups, AmmoGroup |     field :user_id, :binary_id | ||||||
|  |     has_many :packs, Pack | ||||||
| 
 | 
 | ||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @type t :: %AmmoType{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           name: String.t(), |           name: String.t(), | ||||||
|           desc: String.t() | nil, |           desc: String.t() | nil, | ||||||
|  |           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(), |           tracer: boolean(), | ||||||
|           incendiary: boolean(), |           incendiary: boolean(), | ||||||
|           blank: boolean(), |           blank: boolean(), | ||||||
|           corrosive: boolean(), |           corrosive: boolean(), | ||||||
|           manufacturer: String.t() | nil, |           cartridge: String.t() | nil, | ||||||
|           upc: String.t() | nil, |           jacket_type: String.t() | nil, | ||||||
|  |           powder_grains_per_charge: integer() | nil, | ||||||
|  |           muzzle_velocity: integer() | nil, | ||||||
|  |           wadding: String.t() | nil, | ||||||
|  |           shot_type: String.t() | nil, | ||||||
|  |           shot_material: String.t() | nil, | ||||||
|  |           shot_size: String.t() | nil, | ||||||
|  |           unfired_length: String.t() | nil, | ||||||
|  |           brass_height: String.t() | nil, | ||||||
|  |           chamber_size: String.t() | nil, | ||||||
|  |           load_grains: integer() | nil, | ||||||
|  |           shot_charge_weight: String.t() | nil, | ||||||
|  |           dram_equivalent: String.t() | nil, | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           user: User.t() | nil, |           packs: [Pack.t()] | nil, | ||||||
|           ammo_groups: [AmmoGroup.t()] | nil, |  | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_ammo_type :: %AmmoType{} |   @type new_type :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type changeset :: Changeset.t(t() | new_type()) | ||||||
|  |   @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, | ||||||
|  |       :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, | ||||||
|  |       :cartridge, | ||||||
|  |       :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 | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |   @spec string_fields() :: [atom()] | ||||||
|  |   defp string_fields, | ||||||
|  |     do: [ | ||||||
|  |       :name, | ||||||
|  |       :desc, | ||||||
|  |       :bullet_type, | ||||||
|  |       :bullet_core, | ||||||
|  |       :caliber, | ||||||
|  |       :case_material, | ||||||
|  |       :powder_type, | ||||||
|  |       :pressure, | ||||||
|  |       :primer_type, | ||||||
|  |       :firing_type, | ||||||
|       :manufacturer, |       :manufacturer, | ||||||
|       :upc |       :upc, | ||||||
|  |       :cartridge, | ||||||
|  |       :jacket_type, | ||||||
|  |       :wadding, | ||||||
|  |       :shot_type, | ||||||
|  |       :shot_material, | ||||||
|  |       :shot_size, | ||||||
|  |       :unfired_length, | ||||||
|  |       :brass_height, | ||||||
|  |       :chamber_size, | ||||||
|  |       :shot_charge_weight, | ||||||
|  |       :dram_equivalent | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: |   @spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset() | ||||||
|           Changeset.t(new_ammo_type()) |   def create_changeset(type, %User{id: user_id}, attrs) do | ||||||
|   def create_changeset(ammo_type, %User{id: user_id}, attrs) do |     changeset = | ||||||
|     ammo_type |       type | ||||||
|       |> change(user_id: user_id) |       |> change(user_id: user_id) | ||||||
|       |> cast(attrs, changeset_fields()) |       |> cast(attrs, changeset_fields()) | ||||||
|  | 
 | ||||||
|  |     string_fields() | ||||||
|  |     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||||
|     |> validate_required([:name, :user_id]) |     |> validate_required([:name, :user_id]) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: |   @spec update_changeset(t() | new_type(), attrs :: map()) :: changeset() | ||||||
|           Changeset.t(t() | new_ammo_type()) |   def update_changeset(type, attrs) do | ||||||
|   def update_changeset(ammo_type, attrs) do |     changeset = | ||||||
|     ammo_type |       type | ||||||
|       |> cast(attrs, changeset_fields()) |       |> cast(attrs, changeset_fields()) | ||||||
|  | 
 | ||||||
|  |     string_fields() | ||||||
|  |     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||||
|     |> validate_required(:name) |     |> validate_required(:name) | ||||||
|   end |   end | ||||||
| end | end | ||||||
| @@ -4,6 +4,7 @@ defmodule Cannery.Application do | |||||||
|   @moduledoc false |   @moduledoc false | ||||||
|  |  | ||||||
|   use Application |   use Application | ||||||
|  |   alias Cannery.Logger | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def start(_type, _args) do |   def start(_type, _args) do | ||||||
| @@ -17,16 +18,24 @@ defmodule Cannery.Application do | |||||||
|       # Start the Endpoint (http/https) |       # Start the Endpoint (http/https) | ||||||
|       CanneryWeb.Endpoint, |       CanneryWeb.Endpoint, | ||||||
|       # Add Oban |       # Add Oban | ||||||
|       {Oban, oban_config()} |       {Oban, oban_config()}, | ||||||
|  |       Cannery.Repo.Migrator | ||||||
|       # Start a worker by calling: Cannery.Worker.start_link(arg) |       # Start a worker by calling: Cannery.Worker.start_link(arg) | ||||||
|       # {Cannery.Worker, arg} |       # {Cannery.Worker, arg} | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     # Automatically migrate on start in prod |     # Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||||
|     children = |     :ok = | ||||||
|       if Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate], |       :telemetry.attach_many( | ||||||
|         do: children ++ [Cannery.Repo.Migrator], |         "oban-logger", | ||||||
|         else: children |         [ | ||||||
|  |           [:oban, :job, :exception], | ||||||
|  |           [:oban, :job, :start], | ||||||
|  |           [:oban, :job, :stop] | ||||||
|  |         ], | ||||||
|  |         &Logger.handle_event/4, | ||||||
|  |         [] | ||||||
|  |       ) | ||||||
|  |  | ||||||
|     # See https://hexdocs.pm/elixir/Supervisor.html |     # See https://hexdocs.pm/elixir/Supervisor.html | ||||||
|     # for other strategies and supported options |     # for other strategies and supported options | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | defmodule Cannery.ComparableDate do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A custom `Date` module that provides a `compare/2` function that is comparable | ||||||
|  |   with nil values | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   @spec compare(Date.t() | any(), Date.t() | any()) :: :lt | :gt | :eq | ||||||
|  |   def compare(%Date{} = date_1, %Date{} = date_2), do: Date.compare(date_1, date_2) | ||||||
|  |   def compare(%Date{}, _date_2), do: :lt | ||||||
|  |   def compare(_date_1, %Date{}), do: :gt | ||||||
|  |   def compare(_date_1, _date_2), do: :eq | ||||||
|  | end | ||||||
							
								
								
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | defmodule Cannery.ComparableDateTime do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A custom `DateTime` module that provides a `compare/2` function that is | ||||||
|  |   comparable with nil values | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   @spec compare(DateTime.t() | any(), DateTime.t() | any()) :: :lt | :gt | :eq | ||||||
|  |   def compare(%DateTime{} = datetime_1, %DateTime{} = datetime_2) do | ||||||
|  |     DateTime.compare(datetime_1, datetime_2) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def compare(%DateTime{}, _datetime_2), do: :lt | ||||||
|  |   def compare(_datetime_1, %DateTime{}), do: :gt | ||||||
|  |   def compare(_datetime_1, _datetime_2), do: :eq | ||||||
|  | end | ||||||
| @@ -5,10 +5,12 @@ defmodule Cannery.Containers do | |||||||
|  |  | ||||||
|   import CanneryWeb.Gettext |   import CanneryWeb.Gettext | ||||||
|   import Ecto.Query, warn: false |   import Ecto.Query, warn: false | ||||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag} |   alias Cannery.{Accounts.User, Ammo.Pack, Repo} | ||||||
|   alias Cannery.Containers.{Container, ContainerTag} |   alias Cannery.Containers.{Container, ContainerTag, Tag} | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|  |  | ||||||
|  |   @container_preloads [:tags] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the list of containers. |   Returns the list of containers. | ||||||
|  |  | ||||||
| @@ -17,16 +19,54 @@ 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}) | ||||||
|  |       [%Container{name: "my cool container"}, ...] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_containers(User.t()) :: [Container.t()] |   @spec list_containers(User.t()) :: [Container.t()] | ||||||
|   def list_containers(%User{id: user_id}) do |   @spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()] | ||||||
|     Repo.all( |   def list_containers(search \\ nil, %User{id: user_id}) do | ||||||
|       from c in Container, |     from(c in Container, | ||||||
|  |       as: :c, | ||||||
|       left_join: t in assoc(c, :tags), |       left_join: t in assoc(c, :tags), | ||||||
|         left_join: ag in assoc(c, :ammo_groups), |       as: :t, | ||||||
|       where: c.user_id == ^user_id, |       where: c.user_id == ^user_id, | ||||||
|       order_by: c.name, |       order_by: c.name, | ||||||
|         preload: [tags: t, ammo_groups: ag] |       distinct: c.id, | ||||||
|  |       preload: ^@container_preloads | ||||||
|  |     ) | ||||||
|  |     |> list_containers_search(search) | ||||||
|  |     |> Repo.all() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp list_containers_search(query, nil), do: query | ||||||
|  |   defp list_containers_search(query, ""), do: query | ||||||
|  |  | ||||||
|  |   defp list_containers_search(query, search) do | ||||||
|  |     trimmed_search = String.trim(search) | ||||||
|  |  | ||||||
|  |     query | ||||||
|  |     |> where( | ||||||
|  |       [c: c, t: t], | ||||||
|  |       fragment( | ||||||
|  |         "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |         c.search, | ||||||
|  |         ^trimmed_search | ||||||
|  |       ) or | ||||||
|  |         fragment( | ||||||
|  |           "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |           t.search, | ||||||
|  |           ^trimmed_search | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     |> order_by( | ||||||
|  |       [c: c], | ||||||
|  |       desc: | ||||||
|  |         fragment( | ||||||
|  |           "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||||
|  |           c.search, | ||||||
|  |           ^trimmed_search | ||||||
|  |         ) | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -52,7 +92,7 @@ defmodule Cannery.Containers do | |||||||
|   @doc """ |   @doc """ | ||||||
|   Gets a single container. |   Gets a single container. | ||||||
|  |  | ||||||
|   Raises `Ecto.NoResultsError` if the Container does not exist. |   Raises `KeyError` if the Container does not exist. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
| @@ -60,20 +100,37 @@ defmodule Cannery.Containers do | |||||||
|       %Container{} |       %Container{} | ||||||
|  |  | ||||||
|       iex> get_container!(456, %User{id: 123}) |       iex> get_container!(456, %User{id: 123}) | ||||||
|       ** (Ecto.NoResultsError) |       ** (KeyError) | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_container!(Container.id(), User.t()) :: Container.t() |   @spec get_container!(Container.id(), User.t()) :: Container.t() | ||||||
|   def get_container!(id, %User{id: user_id}) do |   def get_container!(id, user) do | ||||||
|     Repo.one!( |     [id] | ||||||
|  |     |> get_containers(user) | ||||||
|  |     |> Map.fetch!(id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets multiple containers. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_containers([123], %User{id: 123}) | ||||||
|  |       %{123 => %Container{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()} | ||||||
|  |   def get_containers(ids, %User{id: user_id}) do | ||||||
|  |     Repo.all( | ||||||
|       from c in Container, |       from c in Container, | ||||||
|         left_join: t in assoc(c, :tags), |  | ||||||
|         left_join: ag in assoc(c, :ammo_groups), |  | ||||||
|         where: c.user_id == ^user_id, |         where: c.user_id == ^user_id, | ||||||
|         where: c.id == ^id, |         where: c.id in ^ids, | ||||||
|         order_by: c.name, |         order_by: c.name, | ||||||
|         preload: [tags: t, ammo_groups: ag] |         preload: ^@container_preloads, | ||||||
|  |         select: {c.id, c} | ||||||
|     ) |     ) | ||||||
|  |     |> Map.new() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -89,9 +146,21 @@ defmodule Cannery.Containers do | |||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec create_container(attrs :: map(), User.t()) :: |   @spec create_container(attrs :: map(), User.t()) :: | ||||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.new_container())} |           {:ok, Container.t()} | {:error, Container.changeset()} | ||||||
|   def create_container(attrs, %User{} = user) do |   def create_container(attrs, %User{} = user) do | ||||||
|     %Container{} |> Container.create_changeset(user, attrs) |> Repo.insert() |     %Container{} | ||||||
|  |     |> Container.create_changeset(user, attrs) | ||||||
|  |     |> Repo.insert() | ||||||
|  |     |> case do | ||||||
|  |       {:ok, container} -> {:ok, container |> preload_container()} | ||||||
|  |       {:error, changeset} -> {:error, changeset} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec preload_container(Container.t()) :: Container.t() | ||||||
|  |   @spec preload_container([Container.t()]) :: [Container.t()] | ||||||
|  |   def preload_container(container) do | ||||||
|  |     container |> Repo.preload(@container_preloads) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -107,9 +176,15 @@ defmodule Cannery.Containers do | |||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec update_container(Container.t(), User.t(), attrs :: map()) :: |   @spec update_container(Container.t(), User.t(), attrs :: map()) :: | ||||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.t())} |           {:ok, Container.t()} | {:error, Container.changeset()} | ||||||
|   def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do |   def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do | ||||||
|     container |> Container.update_changeset(attrs) |> Repo.update() |     container | ||||||
|  |     |> Container.update_changeset(attrs) | ||||||
|  |     |> Repo.update() | ||||||
|  |     |> case do | ||||||
|  |       {:ok, container} -> {:ok, container |> preload_container()} | ||||||
|  |       {:error, changeset} -> {:error, changeset} | ||||||
|  |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
| @@ -125,23 +200,28 @@ defmodule Cannery.Containers do | |||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec delete_container(Container.t(), User.t()) :: |   @spec delete_container(Container.t(), User.t()) :: | ||||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.t())} |           {: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 ag in Pack, | ||||||
|         where: ag.container_id == ^container.id, |         where: ag.container_id == ^container.id, | ||||||
|         select: count(ag.id) |         select: count(ag.id) | ||||||
|     ) |     ) | ||||||
|     |> case do |     |> case do | ||||||
|       0 -> |       0 -> | ||||||
|         container |> Repo.delete() |         container | ||||||
|  |         |> Repo.delete() | ||||||
|  |         |> case do | ||||||
|  |           {:ok, container} -> {:ok, container |> preload_container()} | ||||||
|  |           {:error, changeset} -> {:error, changeset} | ||||||
|  |         end | ||||||
|  |  | ||||||
|       _amount -> |       _amount -> | ||||||
|         error = dgettext("errors", "Container must be empty before deleting") |         error = dgettext("errors", "Container must be empty before deleting") | ||||||
|  |  | ||||||
|         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 | ||||||
| @@ -175,8 +255,11 @@ defmodule Cannery.Containers do | |||||||
|         %Container{user_id: user_id} = container, |         %Container{user_id: user_id} = container, | ||||||
|         %Tag{user_id: user_id} = tag, |         %Tag{user_id: user_id} = tag, | ||||||
|         %User{id: user_id} |         %User{id: user_id} | ||||||
|       ), |       ) do | ||||||
|       do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!() |     %ContainerTag{} | ||||||
|  |     |> ContainerTag.create_changeset(tag, container) | ||||||
|  |     |> Repo.insert!() | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Removes a tag from a container |   Removes a tag from a container | ||||||
| @@ -187,45 +270,175 @@ defmodule Cannery.Containers do | |||||||
|       %Container{} |       %Container{} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: non_neg_integer() |   @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: {non_neg_integer(), [ContainerTag.t()]} | ||||||
|   def remove_tag!( |   def remove_tag!( | ||||||
|         %Container{id: container_id, user_id: user_id}, |         %Container{id: container_id, user_id: user_id}, | ||||||
|         %Tag{id: tag_id, user_id: user_id}, |         %Tag{id: tag_id, user_id: user_id}, | ||||||
|         %User{id: user_id} |         %User{id: user_id} | ||||||
|       ) do |       ) do | ||||||
|     {count, _} = |     {count, results} = | ||||||
|       Repo.delete_all( |       Repo.delete_all( | ||||||
|         from ct in ContainerTag, |         from ct in ContainerTag, | ||||||
|           where: ct.container_id == ^container_id, |           where: ct.container_id == ^container_id, | ||||||
|           where: ct.tag_id == ^tag_id |           where: ct.tag_id == ^tag_id, | ||||||
|  |           select: ct | ||||||
|       ) |       ) | ||||||
|  |  | ||||||
|     if count == 0, do: raise("could not delete container tag"), else: count |     if count == 0, do: raise("could not delete container tag"), else: {count, results} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # Container Tags | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the list of tags. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> list_tags(%User{id: 123}) | ||||||
|  |       [%Tag{}, ...] | ||||||
|  |  | ||||||
|  |       iex> list_tags("cool", %User{id: 123}) | ||||||
|  |       [%Tag{name: "my cool tag"}, ...] | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec list_tags(User.t()) :: [Tag.t()] | ||||||
|  |   @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] | ||||||
|  |   def list_tags(search \\ nil, user) | ||||||
|  |  | ||||||
|  |   def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||||
|  |     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) | ||||||
|  |  | ||||||
|  |   def list_tags(search, %{id: user_id}) when search |> is_binary() do | ||||||
|  |     trimmed_search = String.trim(search) | ||||||
|  |  | ||||||
|  |     Repo.all( | ||||||
|  |       from t in Tag, | ||||||
|  |         where: t.user_id == ^user_id, | ||||||
|  |         where: | ||||||
|  |           fragment( | ||||||
|  |             "? @@ websearch_to_tsquery('english', ?)", | ||||||
|  |             t.search, | ||||||
|  |             ^trimmed_search | ||||||
|  |           ), | ||||||
|  |         order_by: { | ||||||
|  |           :desc, | ||||||
|  |           fragment( | ||||||
|  |             "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||||
|  |             t.search, | ||||||
|  |             ^trimmed_search | ||||||
|  |           ) | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns number of rounds in container. If data is already preloaded, then |   Gets a single tag. | ||||||
|   there will be no db hit. |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_tag(123, %User{id: 123}) | ||||||
|  |       {:ok, %Tag{}} | ||||||
|  |  | ||||||
|  |       iex> get_tag(456, %User{id: 123}) | ||||||
|  |       {:error, :not_found} | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_container_ammo_group_count!(Container.t()) :: non_neg_integer() |   @spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, :not_found} | ||||||
|   def get_container_ammo_group_count!(%Container{} = container) do |   def get_tag(id, %User{id: user_id}) do | ||||||
|     container |     Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) | ||||||
|     |> Repo.preload(:ammo_groups) |     |> case do | ||||||
|     |> Map.fetch!(:ammo_groups) |       nil -> {:error, :not_found} | ||||||
|     |> Enum.reject(fn %{count: count} -> count == 0 end) |       tag -> {:ok, tag} | ||||||
|     |> Enum.count() |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns number of rounds in container. If data is already preloaded, then |   Gets a single tag. | ||||||
|   there will be no db hit. |  | ||||||
|  |   Raises `Ecto.NoResultsError` if the Tag does not exist. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_tag!(123, %User{id: 123}) | ||||||
|  |       %Tag{} | ||||||
|  |  | ||||||
|  |       iex> get_tag!(456, %User{id: 123}) | ||||||
|  |       ** (Ecto.NoResultsError) | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_container_rounds!(Container.t()) :: non_neg_integer() |   @spec get_tag!(Tag.id(), User.t()) :: Tag.t() | ||||||
|   def get_container_rounds!(%Container{} = container) do |   def get_tag!(id, %User{id: user_id}) do | ||||||
|     container |     Repo.one!( | ||||||
|     |> Repo.preload(:ammo_groups) |       from t in Tag, | ||||||
|     |> Map.fetch!(:ammo_groups) |         where: t.id == ^id, | ||||||
|     |> Enum.map(fn %{count: count} -> count end) |         where: t.user_id == ^user_id | ||||||
|     |> Enum.sum() |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Creates a tag. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> create_tag(%{field: value}, %User{id: 123}) | ||||||
|  |       {:ok, %Tag{}} | ||||||
|  |  | ||||||
|  |       iex> create_tag(%{field: bad_value}, %User{id: 123}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec create_tag(attrs :: map(), User.t()) :: | ||||||
|  |           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||||
|  |   def create_tag(attrs, %User{} = user) do | ||||||
|  |     %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Updates a tag. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> update_tag(tag, %{field: new_value}, %User{id: 123}) | ||||||
|  |       {:ok, %Tag{}} | ||||||
|  |  | ||||||
|  |       iex> update_tag(tag, %{field: bad_value}, %User{id: 123}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec update_tag(Tag.t(), attrs :: map(), User.t()) :: | ||||||
|  |           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||||
|  |   def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}) do | ||||||
|  |     tag |> Tag.update_changeset(attrs) |> Repo.update() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Deletes a tag. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> delete_tag(tag, %User{id: 123}) | ||||||
|  |       {:ok, %Tag{}} | ||||||
|  |  | ||||||
|  |       iex> delete_tag(tag, %User{id: 123}) | ||||||
|  |       {:error, %Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||||
|  |   def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}) do | ||||||
|  |     tag |> Repo.delete() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Deletes a tag. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> delete_tag!(tag, %User{id: 123}) | ||||||
|  |       %Tag{} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() | ||||||
|  |   def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}) do | ||||||
|  |     tag |> Repo.delete!() | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -6,8 +6,7 @@ defmodule Cannery.Containers.Container do | |||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|   alias Ecto.{Changeset, UUID} |   alias Ecto.{Changeset, UUID} | ||||||
|   alias Cannery.Containers.{Container, ContainerTag} |   alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag} | ||||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -26,46 +25,46 @@ defmodule Cannery.Containers.Container do | |||||||
|     field :location, :string |     field :location, :string | ||||||
|     field :type, :string |     field :type, :string | ||||||
|  |  | ||||||
|     belongs_to :user, User |     field :user_id, :binary_id | ||||||
|  |  | ||||||
|     has_many :ammo_groups, AmmoGroup |  | ||||||
|     many_to_many :tags, Tag, join_through: ContainerTag |     many_to_many :tags, Tag, join_through: ContainerTag | ||||||
|  |  | ||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %Container{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           name: String.t(), |           name: String.t(), | ||||||
|           desc: String.t(), |           desc: String.t(), | ||||||
|           location: String.t(), |           location: String.t(), | ||||||
|           type: String.t(), |           type: String.t(), | ||||||
|           user: User.t(), |  | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           ammo_groups: [AmmoGroup.t()] | nil, |  | ||||||
|           tags: [Tag.t()] | nil, |           tags: [Tag.t()] | nil, | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_container :: %Container{} |   @type new_container :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type changeset :: Changeset.t(t() | new_container()) | ||||||
|  |  | ||||||
|   @doc false |   @doc false | ||||||
|   @spec create_changeset(new_container(), User.t(), attrs :: map()) :: |   @spec create_changeset(new_container(), User.t(), attrs :: map()) :: changeset() | ||||||
|           Changeset.t(new_container()) |  | ||||||
|   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, [:name, :desc, :type, :location]) | ||||||
|  |     |> validate_length(:name, max: 255) | ||||||
|  |     |> validate_length(:type, max: 255) | ||||||
|     |> validate_required([:name, :type, :user_id]) |     |> validate_required([:name, :type, :user_id]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc false |   @doc false | ||||||
|   @spec update_changeset(t() | new_container(), attrs :: map()) :: |   @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() | ||||||
|           Changeset.t(t() | new_container()) |  | ||||||
|   def update_changeset(container, attrs) do |   def update_changeset(container, attrs) do | ||||||
|     container |     container | ||||||
|     |> cast(attrs, [:name, :desc, :type, :location]) |     |> cast(attrs, [:name, :desc, :type, :location]) | ||||||
|  |     |> validate_length(:name, max: 255) | ||||||
|  |     |> validate_length(:type, max: 255) | ||||||
|     |> validate_required([:name, :type]) |     |> validate_required([:name, :type]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| defmodule Cannery.Containers.ContainerTag do | defmodule Cannery.Containers.ContainerTag do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   Thru-table struct for associating Cannery.Containers.Container and |   Thru-table struct for associating Cannery.Containers.Container and | ||||||
|   Cannery.Tags.Tag. |   Cannery.Containers.Tag. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|   alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag} |   alias Cannery.Containers.{Container, Tag} | ||||||
|   alias Ecto.{Changeset, UUID} |   alias Ecto.{Changeset, UUID} | ||||||
|  |  | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |   @primary_key {:id, :binary_id, autogenerate: true} | ||||||
| @@ -18,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do | |||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %ContainerTag{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           container: Container.t(), |           container: Container.t(), | ||||||
|           container_id: Container.id(), |           container_id: Container.id(), | ||||||
| @@ -27,12 +27,12 @@ defmodule Cannery.Containers.ContainerTag do | |||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_container_tag :: %ContainerTag{} |   @type new_container_tag :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|  |   @type changeset :: Changeset.t(t() | new_container_tag()) | ||||||
|  |  | ||||||
|   @doc false |   @doc false | ||||||
|   @spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: |   @spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: changeset() | ||||||
|           Changeset.t(new_container_tag()) |  | ||||||
|   def create_changeset( |   def create_changeset( | ||||||
|         container_tag, |         container_tag, | ||||||
|         %Tag{id: tag_id, user_id: user_id}, |         %Tag{id: tag_id, user_id: user_id}, | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| defmodule Cannery.Tags.Tag do | defmodule Cannery.Containers.Tag do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   Tags are added to containers to help organize, and can include custom-defined |   Tags are added to containers to help organize, and can include custom-defined | ||||||
|   text and bg colors. |   text and bg colors. | ||||||
| @@ -6,8 +6,8 @@ defmodule Cannery.Tags.Tag do | |||||||
| 
 | 
 | ||||||
|   use Ecto.Schema |   use Ecto.Schema | ||||||
|   import Ecto.Changeset |   import Ecto.Changeset | ||||||
|  |   alias Cannery.Accounts.User | ||||||
|   alias Ecto.{Changeset, UUID} |   alias Ecto.{Changeset, UUID} | ||||||
|   alias Cannery.{Accounts.User, Tags.Tag} |  | ||||||
| 
 | 
 | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -23,38 +23,44 @@ defmodule Cannery.Tags.Tag do | |||||||
|     field :bg_color, :string |     field :bg_color, :string | ||||||
|     field :text_color, :string |     field :text_color, :string | ||||||
| 
 | 
 | ||||||
|     belongs_to :user, User |     field :user_id, :binary_id | ||||||
| 
 | 
 | ||||||
|     timestamps() |     timestamps() | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @type t :: %Tag{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |           id: id(), | ||||||
|           name: String.t(), |           name: String.t(), | ||||||
|           bg_color: String.t(), |           bg_color: String.t(), | ||||||
|           text_color: String.t(), |           text_color: String.t(), | ||||||
|           user: User.t() | nil, |  | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: NaiveDateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: NaiveDateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_tag() :: %Tag{} |   @type new_tag() :: %__MODULE__{} | ||||||
|   @type id() :: UUID.t() |   @type id() :: UUID.t() | ||||||
|  |   @type changeset() :: Changeset.t(t() | new_tag()) | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec create_changeset(new_tag(), User.t(), attrs :: map()) :: Changeset.t(new_tag()) |   @spec create_changeset(new_tag(), User.t(), attrs :: map()) :: changeset() | ||||||
|   def create_changeset(tag, %User{id: user_id}, attrs) do |   def create_changeset(tag, %User{id: user_id}, attrs) do | ||||||
|     tag |     tag | ||||||
|     |> change(user_id: user_id) |     |> change(user_id: user_id) | ||||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) |     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||||
|  |     |> validate_length(:name, max: 255) | ||||||
|  |     |> validate_length(:bg_color, max: 12) | ||||||
|  |     |> validate_length(:text_color, max: 12) | ||||||
|     |> validate_required([:name, :bg_color, :text_color, :user_id]) |     |> validate_required([:name, :bg_color, :text_color, :user_id]) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @doc false |   @doc false | ||||||
|   @spec update_changeset(t() | new_tag(), attrs :: map()) :: Changeset.t(t() | new_tag()) |   @spec update_changeset(t() | new_tag(), attrs :: map()) :: changeset() | ||||||
|   def update_changeset(tag, attrs) do |   def update_changeset(tag, attrs) do | ||||||
|     tag |     tag | ||||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) |     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||||
|  |     |> validate_length(:name, max: 255) | ||||||
|  |     |> validate_length(:bg_color, max: 12) | ||||||
|  |     |> validate_length(:text_color, max: 12) | ||||||
|     |> validate_required([:name, :bg_color, :text_color]) |     |> validate_required([:name, :bg_color, :text_color]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
| @@ -1,156 +0,0 @@ | |||||||
| defmodule Cannery.Invites do |  | ||||||
|   @moduledoc """ |  | ||||||
|   The Invites context. |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |  | ||||||
|   alias Cannery.{Accounts.User, Invites.Invite, Repo} |  | ||||||
|   alias Ecto.Changeset |  | ||||||
|  |  | ||||||
|   @invite_token_length 20 |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Returns the list of invites. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> list_invites(%User{id: 123, role: :admin}) |  | ||||||
|       [%Invite{}, ...] |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec list_invites(User.t()) :: [Invite.t()] |  | ||||||
|   def list_invites(%User{role: :admin}) do |  | ||||||
|     Repo.all(from i in Invite, order_by: i.name) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Gets a single invite. |  | ||||||
|  |  | ||||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) |  | ||||||
|       %Invite{} |  | ||||||
|  |  | ||||||
|       iex> get_invite!(456, %User{id: 123, role: :admin}) |  | ||||||
|       ** (Ecto.NoResultsError) |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() |  | ||||||
|   def get_invite!(id, %User{role: :admin}) do |  | ||||||
|     Repo.get!(Invite, id) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Returns a valid invite or nil based on the attempted token |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> get_invite_by_token("valid_token") |  | ||||||
|       %Invite{} |  | ||||||
|  |  | ||||||
|       iex> get_invite_by_token("invalid_token") |  | ||||||
|       nil |  | ||||||
|   """ |  | ||||||
|   @spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil |  | ||||||
|   def get_invite_by_token(nil), do: nil |  | ||||||
|   def get_invite_by_token(""), do: nil |  | ||||||
|  |  | ||||||
|   def get_invite_by_token(token) do |  | ||||||
|     Repo.one( |  | ||||||
|       from(i in Invite, |  | ||||||
|         where: i.token == ^token and i.disabled_at |> is_nil() |  | ||||||
|       ) |  | ||||||
|     ) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been |  | ||||||
|   completely used. |  | ||||||
|   """ |  | ||||||
|   @spec use_invite!(Invite.t()) :: Invite.t() |  | ||||||
|   def use_invite!(%Invite{uses_left: nil} = invite), do: invite |  | ||||||
|  |  | ||||||
|   def use_invite!(%Invite{uses_left: uses_left} = invite) do |  | ||||||
|     new_uses_left = uses_left - 1 |  | ||||||
|  |  | ||||||
|     attrs = |  | ||||||
|       if new_uses_left <= 0 do |  | ||||||
|         now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) |  | ||||||
|         %{"uses_left" => 0, "disabled_at" => now} |  | ||||||
|       else |  | ||||||
|         %{"uses_left" => new_uses_left} |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update!() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Creates a invite. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) |  | ||||||
|       {:ok, %Invite{}} |  | ||||||
|  |  | ||||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec create_invite(User.t(), attrs :: map()) :: |  | ||||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())} |  | ||||||
|   def create_invite(%User{role: :admin} = user, attrs) do |  | ||||||
|     token = |  | ||||||
|       :crypto.strong_rand_bytes(@invite_token_length) |  | ||||||
|       |> Base.url_encode64() |  | ||||||
|       |> binary_part(0, @invite_token_length) |  | ||||||
|  |  | ||||||
|     %Invite{} |> Invite.create_changeset(user, token, attrs) |> Repo.insert() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Updates a invite. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) |  | ||||||
|       {:ok, %Invite{}} |  | ||||||
|  |  | ||||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: |  | ||||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} |  | ||||||
|   def update_invite(invite, attrs, %User{role: :admin}), |  | ||||||
|     do: invite |> Invite.update_changeset(attrs) |> Repo.update() |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Deletes a invite. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) |  | ||||||
|       {:ok, %Invite{}} |  | ||||||
|  |  | ||||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec delete_invite(Invite.t(), User.t()) :: |  | ||||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} |  | ||||||
|   def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete() |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Deletes a invite. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) |  | ||||||
|       %Invite{} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() |  | ||||||
|   def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!() |  | ||||||
| end |  | ||||||
							
								
								
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | defmodule Cannery.Logger do | ||||||
|  |   @moduledoc """ | ||||||
|  |   Custom logger for telemetry events | ||||||
|  |  | ||||||
|  |   Oban implementation taken from | ||||||
|  |   https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   require Logger | ||||||
|  |  | ||||||
|  |   def handle_event([:oban, :job, :exception], measure, %{stacktrace: stacktrace} = meta, _config) do | ||||||
|  |     data = | ||||||
|  |       get_oban_job_data(meta, measure) | ||||||
|  |       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||||
|  |       |> pretty_encode() | ||||||
|  |  | ||||||
|  |     Logger.error(meta.reason, data: data) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||||
|  |     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||||
|  |     Logger.info("Started oban job", data: data) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event([:oban, :job, :stop], measure, meta, _config) do | ||||||
|  |     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||||
|  |     Logger.info("Finished oban job", data: data) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do | ||||||
|  |     data = | ||||||
|  |       get_oban_job_data(meta, measure) | ||||||
|  |       |> Map.put(:event, unhandled_event) | ||||||
|  |       |> pretty_encode() | ||||||
|  |  | ||||||
|  |     Logger.warning("Unhandled oban job event", data: data) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event(unhandled_event, measure, meta, config) do | ||||||
|  |     data = | ||||||
|  |       pretty_encode(%{ | ||||||
|  |         event: unhandled_event, | ||||||
|  |         meta: meta, | ||||||
|  |         measurements: measure, | ||||||
|  |         config: config | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |     Logger.warning("Unhandled telemetry event", data: data) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_oban_job_data(%{job: job}, measure) do | ||||||
|  |     %{ | ||||||
|  |       job: job |> Map.take([:id, :args, :meta, :queue, :worker]), | ||||||
|  |       measurements: measure | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp pretty_encode(data) do | ||||||
|  |     data | ||||||
|  |     |> Jason.encode!() | ||||||
|  |     |> Jason.Formatter.pretty_print() | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,7 +9,9 @@ defmodule Cannery.Release do | |||||||
|  |  | ||||||
|   def rollback(repo, version) do |   def rollback(repo, version) do | ||||||
|     load_app() |     load_app() | ||||||
|     {:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) |  | ||||||
|  |     {:ok, _fun_return, _apps} = | ||||||
|  |       Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp load_app do |   defp load_app do | ||||||
| @@ -20,7 +22,8 @@ defmodule Cannery.Release do | |||||||
|     load_app() |     load_app() | ||||||
|  |  | ||||||
|     for repo <- Application.fetch_env!(@app, :ecto_repos) do |     for repo <- Application.fetch_env!(@app, :ecto_repos) do | ||||||
|       {:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) |       {:ok, _fun_return, _apps} = | ||||||
|  |         Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| defmodule Cannery.Repo.Migrator do | defmodule Cannery.Repo.Migrator do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   Genserver to automatically run migrations in prod env |   Genserver to automatically perform all migration on app start | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use GenServer |   use GenServer | ||||||
| @@ -11,12 +11,15 @@ defmodule Cannery.Repo.Migrator do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def init(_opts) do |   def init(_opts) do | ||||||
|     migrate!() |     {:ok, if(automigrate_enabled?(), do: migrate!())} | ||||||
|     {:ok, nil} |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def migrate! do |   def migrate! do | ||||||
|     path = Application.app_dir(:cannery, "priv/repo/migrations") |     path = Application.app_dir(:cannery, "priv/repo/migrations") | ||||||
|     Ecto.Migrator.run(Cannery.Repo, path, :up, all: true) |     Ecto.Migrator.run(Cannery.Repo, path, :up, all: true) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   defp automigrate_enabled? do | ||||||
|  |     Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate] | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,123 +0,0 @@ | |||||||
| defmodule Cannery.Tags do |  | ||||||
|   @moduledoc """ |  | ||||||
|   The Tags context. |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |  | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Cannery.{Accounts.User, Repo, Tags.Tag} |  | ||||||
|   alias Ecto.Changeset |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Returns the list of tags. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> list_tags(%User{id: 123}) |  | ||||||
|       [%Tag{}, ...] |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec list_tags(User.t()) :: [Tag.t()] |  | ||||||
|   def list_tags(%{id: user_id}), |  | ||||||
|     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Gets a single tag. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> get_tag(123, %User{id: 123}) |  | ||||||
|       {:ok, %Tag{}} |  | ||||||
|  |  | ||||||
|       iex> get_tag(456, %User{id: 123}) |  | ||||||
|       {:error, "tag not found"} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, String.t()} |  | ||||||
|   def get_tag(id, %User{id: user_id}) do |  | ||||||
|     Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) |  | ||||||
|     |> case do |  | ||||||
|       nil -> {:error, dgettext("errors", "Tag not found")} |  | ||||||
|       tag -> {:ok, tag} |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Gets a single tag. |  | ||||||
|  |  | ||||||
|   Raises `Ecto.NoResultsError` if the Tag does not exist. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> get_tag!(123, %User{id: 123}) |  | ||||||
|       %Tag{} |  | ||||||
|  |  | ||||||
|       iex> get_tag!(456, %User{id: 123}) |  | ||||||
|       ** (Ecto.NoResultsError) |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec get_tag!(Tag.id(), User.t()) :: Tag.t() |  | ||||||
|   def get_tag!(id, %User{id: user_id}), |  | ||||||
|     do: Repo.one!(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Creates a tag. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> create_tag(%{field: value}, %User{id: 123}) |  | ||||||
|       {:ok, %Tag{}} |  | ||||||
|  |  | ||||||
|       iex> create_tag(%{field: bad_value}, %User{id: 123}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec create_tag(attrs :: map(), User.t()) :: |  | ||||||
|           {:ok, Tag.t()} | {:error, Changeset.t(Tag.new_tag())} |  | ||||||
|   def create_tag(attrs, %User{} = user), |  | ||||||
|     do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert() |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Updates a tag. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> update_tag(tag, %{field: new_value}, %User{id: 123}) |  | ||||||
|       {:ok, %Tag{}} |  | ||||||
|  |  | ||||||
|       iex> update_tag(tag, %{field: bad_value}, %User{id: 123}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec update_tag(Tag.t(), attrs :: map(), User.t()) :: |  | ||||||
|           {:ok, Tag.t()} | {:error, Changeset.t(Tag.t())} |  | ||||||
|   def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}), |  | ||||||
|     do: tag |> Tag.update_changeset(attrs) |> Repo.update() |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Deletes a tag. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> delete_tag(tag, %User{id: 123}) |  | ||||||
|       {:ok, %Tag{}} |  | ||||||
|  |  | ||||||
|       iex> delete_tag(tag, %User{id: 123}) |  | ||||||
|       {:error, %Changeset{}} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Changeset.t(Tag.t())} |  | ||||||
|   def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete() |  | ||||||
|  |  | ||||||
|   @doc """ |  | ||||||
|   Deletes a tag. |  | ||||||
|  |  | ||||||
|   ## Examples |  | ||||||
|  |  | ||||||
|       iex> delete_tag!(tag, %User{id: 123}) |  | ||||||
|       %Tag{} |  | ||||||
|  |  | ||||||
|   """ |  | ||||||
|   @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() |  | ||||||
|   def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!() |  | ||||||
| end |  | ||||||
| @@ -44,8 +44,7 @@ defmodule CanneryWeb do | |||||||
|  |  | ||||||
|   def live_view do |   def live_view do | ||||||
|     quote do |     quote do | ||||||
|       use Phoenix.LiveView, |       use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live} | ||||||
|         layout: {CanneryWeb.LayoutView, "live.html"} |  | ||||||
|  |  | ||||||
|       on_mount CanneryWeb.InitAssigns |       on_mount CanneryWeb.InitAssigns | ||||||
|       unquote(view_helpers()) |       unquote(view_helpers()) | ||||||
| @@ -72,16 +71,14 @@ defmodule CanneryWeb do | |||||||
|     quote do |     quote do | ||||||
|       use Phoenix.Router |       use Phoenix.Router | ||||||
|  |  | ||||||
|  |       import Phoenix.{Controller, LiveView.Router} | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||||
|       import Plug.Conn |       import Plug.Conn | ||||||
|       import Phoenix.Controller |  | ||||||
|       import Phoenix.LiveView.Router |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def channel do |   def channel do | ||||||
|     quote do |     quote do | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |  | ||||||
|       use Phoenix.Channel |       use Phoenix.Channel | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||||
|       import CanneryWeb.Gettext |       import CanneryWeb.Gettext | ||||||
| @@ -95,15 +92,11 @@ defmodule CanneryWeb do | |||||||
|       use Phoenix.HTML |       use Phoenix.HTML | ||||||
|  |  | ||||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) |       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |  | ||||||
|       import Phoenix.Component |  | ||||||
|  |  | ||||||
|       # Import basic rendering functionality (render, render_layout, etc) |       # Import basic rendering functionality (render, render_layout, etc) | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers} | ||||||
|       import Phoenix.View |       import Phoenix.{Component, View} | ||||||
|  |  | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       alias CanneryWeb.Endpoint | ||||||
|       import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers} |  | ||||||
|       alias CanneryWeb.Router.Helpers, as: Routes |       alias CanneryWeb.Router.Helpers, as: Routes | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -1,78 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.AddShotGroupComponent do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Livecomponent that can create a ShotGroup |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |  | ||||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} |  | ||||||
|   alias Phoenix.LiveView.{JS, Socket} |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   @spec update( |  | ||||||
|           %{ |  | ||||||
|             required(:current_user) => User.t(), |  | ||||||
|             required(:ammo_group) => AmmoGroup.t(), |  | ||||||
|             optional(any()) => any() |  | ||||||
|           }, |  | ||||||
|           Socket.t() |  | ||||||
|         ) :: {:ok, Socket.t()} |  | ||||||
|   def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do |  | ||||||
|     changeset = |  | ||||||
|       %ShotGroup{date: NaiveDateTime.utc_now(), count: 1} |  | ||||||
|       |> ShotGroup.create_changeset(current_user, ammo_group, %{}) |  | ||||||
|  |  | ||||||
|     {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "validate", |  | ||||||
|         %{"shot_group" => shot_group_params}, |  | ||||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     params = shot_group_params |> process_params(ammo_group) |  | ||||||
|  |  | ||||||
|     changeset = |  | ||||||
|       %ShotGroup{} |  | ||||||
|       |> ShotGroup.create_changeset(current_user, ammo_group, params) |  | ||||||
|       |> Map.put(:action, :validate) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> assign(:changeset, changeset)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def handle_event( |  | ||||||
|         "save", |  | ||||||
|         %{"shot_group" => shot_group_params}, |  | ||||||
|         %{ |  | ||||||
|           assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to} |  | ||||||
|         } = socket |  | ||||||
|       ) do |  | ||||||
|     socket = |  | ||||||
|       shot_group_params |  | ||||||
|       |> process_params(ammo_group) |  | ||||||
|       |> ActivityLog.create_shot_group(current_user, ammo_group) |  | ||||||
|       |> case do |  | ||||||
|         {:ok, _shot_group} -> |  | ||||||
|           prompt = dgettext("prompts", "Shots recorded successfully") |  | ||||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) |  | ||||||
|  |  | ||||||
|         {:error, %Ecto.Changeset{} = changeset} -> |  | ||||||
|           socket |> assign(changeset: changeset) |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     {:noreply, socket} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   # calculate count from shots left |  | ||||||
|   defp process_params(params, %AmmoGroup{count: count}) do |  | ||||||
|     new_count = |  | ||||||
|       if params |> Map.get("ammo_left", "0") == "" do |  | ||||||
|         "0" |  | ||||||
|       else |  | ||||||
|         params |> Map.get("ammo_left", "0") |  | ||||||
|       end |  | ||||||
|       |> String.to_integer() |  | ||||||
|  |  | ||||||
|     params |> Map.put("count", count - new_count) |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										82
									
								
								lib/cannery_web/components/add_shot_record_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								lib/cannery_web/components/add_shot_record_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | defmodule CanneryWeb.Components.AddShotRecordComponent do | ||||||
|  |   @moduledoc """ | ||||||
|  |   Livecomponent that can create a ShotRecord | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo.Pack} | ||||||
|  |   alias Ecto.Changeset | ||||||
|  |   alias Phoenix.LiveView.{JS, Socket} | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   @spec update( | ||||||
|  |           %{ | ||||||
|  |             required(:current_user) => User.t(), | ||||||
|  |             required(:pack) => Pack.t(), | ||||||
|  |             optional(any()) => any() | ||||||
|  |           }, | ||||||
|  |           Socket.t() | ||||||
|  |         ) :: {:ok, Socket.t()} | ||||||
|  |   def update(%{pack: pack, current_user: current_user} = assigns, socket) do | ||||||
|  |     changeset = | ||||||
|  |       %ShotRecord{date: Date.utc_today()} | ||||||
|  |       |> ShotRecord.create_changeset(current_user, pack, %{}) | ||||||
|  |  | ||||||
|  |     {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   def handle_event( | ||||||
|  |         "validate", | ||||||
|  |         %{"shot_record" => shot_record_params}, | ||||||
|  |         %{assigns: %{pack: pack, current_user: current_user}} = socket | ||||||
|  |       ) do | ||||||
|  |     params = shot_record_params |> process_params(pack) | ||||||
|  |  | ||||||
|  |     changeset = %ShotRecord{} |> ShotRecord.create_changeset(current_user, pack, params) | ||||||
|  |  | ||||||
|  |     changeset = | ||||||
|  |       case changeset |> Changeset.apply_action(:validate) do | ||||||
|  |         {:ok, _data} -> changeset | ||||||
|  |         {:error, changeset} -> changeset | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     {:noreply, socket |> assign(:changeset, changeset)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event( | ||||||
|  |         "save", | ||||||
|  |         %{"shot_record" => shot_record_params}, | ||||||
|  |         %{ | ||||||
|  |           assigns: %{pack: pack, current_user: current_user, return_to: return_to} | ||||||
|  |         } = socket | ||||||
|  |       ) do | ||||||
|  |     socket = | ||||||
|  |       shot_record_params | ||||||
|  |       |> process_params(pack) | ||||||
|  |       |> ActivityLog.create_shot_record(current_user, pack) | ||||||
|  |       |> case do | ||||||
|  |         {:ok, _shot_record} -> | ||||||
|  |           prompt = dgettext("prompts", "Shots recorded successfully") | ||||||
|  |           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||||
|  |  | ||||||
|  |         {:error, %Changeset{} = changeset} -> | ||||||
|  |           socket |> assign(changeset: changeset) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     {:noreply, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # calculate count from shots left | ||||||
|  |   defp process_params(params, %Pack{count: count}) do | ||||||
|  |     shot_record_count = | ||||||
|  |       if params |> Map.get("ammo_left", "") == "" do | ||||||
|  |         nil | ||||||
|  |       else | ||||||
|  |         new_count = params |> Map.get("ammo_left") |> String.to_integer() | ||||||
|  |         count - new_count | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     params |> Map.put("count", shot_record_count) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -6,29 +6,30 @@ | |||||||
|   <.form |   <.form | ||||||
|     :let={f} |     :let={f} | ||||||
|     for={@changeset} |     for={@changeset} | ||||||
|     id="shot-group-form" |     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" |     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-target={@myself} | ||||||
|     phx-change="validate" |     phx-change="validate" | ||||||
|     phx-submit="save" |     phx-submit="save" | ||||||
|   > |   > | ||||||
|     <%= if @changeset.action && not @changeset.valid? do %> |     <div | ||||||
|       <div class="invalid-feedback col-span-3 text-center"> |       :if={@changeset.action && not @changeset.valid?()} | ||||||
|  |       class="invalid-feedback col-span-3 text-center" | ||||||
|  |     > | ||||||
|       <%= changeset_errors(@changeset) %> |       <%= changeset_errors(@changeset) %> | ||||||
|     </div> |     </div> | ||||||
|     <% end %> |  | ||||||
| 
 | 
 | ||||||
|     <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> |     <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= number_input(f, :ammo_left, |     <%= number_input(f, :ammo_left, | ||||||
|       min: 0, |       min: 0, | ||||||
|       max: @ammo_group.count - 1, |       max: @pack.count - 1, | ||||||
|       placeholder: gettext("Rounds left"), |       placeholder: gettext("Rounds left"), | ||||||
|       class: "input input-primary" |       class: "input input-primary" | ||||||
|     ) %> |     ) %> | ||||||
|     <button |     <button | ||||||
|       type="button" |       type="button" | ||||||
|       class="mx-2 my-1 text-sm btn btn-primary" |       class="mx-2 my-1 text-sm btn btn-primary" | ||||||
|       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")} |       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")} | ||||||
|     > |     > | ||||||
|       <%= gettext("Used up!") %> |       <%= gettext("Used up!") %> | ||||||
|     </button> |     </button> | ||||||
| @@ -36,13 +37,16 @@ | |||||||
| 
 | 
 | ||||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> |     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= textarea(f, :notes, |     <%= textarea(f, :notes, | ||||||
|  |       id: "add-shot-record-form-notes", | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|       placeholder: "Really great weather", |       maxlength: 255, | ||||||
|       phx_hook: "MaintainAttrs" |       placeholder: gettext("Really great weather"), | ||||||
|  |       phx_hook: "MaintainAttrs", | ||||||
|  |       phx_update: "ignore" | ||||||
|     ) %> |     ) %> | ||||||
|     <%= error_tag(f, :notes, "col-span-3") %> |     <%= error_tag(f, :notes, "col-span-3") %> | ||||||
| 
 | 
 | ||||||
|     <%= label(f, :date, gettext("Date (UTC)"), class: "title text-lg text-primary-600") %> |     <%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= date_input(f, :date, |     <%= date_input(f, :date, | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|       phx_update: "ignore", |       phx_update: "ignore", | ||||||
| @@ -1,91 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.AmmoGroupCard do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Display card for an ammo group |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Repo} |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|  |  | ||||||
|   attr :ammo_group, AmmoGroup, required: true |  | ||||||
|   attr :show_container, :boolean, default: false |  | ||||||
|   slot(:inner_block) |  | ||||||
|  |  | ||||||
|   def ammo_group_card(%{ammo_group: ammo_group} = assigns) do |  | ||||||
|     assigns = |  | ||||||
|       %{show_container: show_container} = assigns |> assign_new(:show_container, fn -> false end) |  | ||||||
|  |  | ||||||
|     preloads = if show_container, do: [:ammo_type, :container], else: [:ammo_type] |  | ||||||
|     ammo_group = ammo_group |> Repo.preload(preloads) |  | ||||||
|     assigns = assigns |> assign(:ammo_group, ammo_group) |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <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: "Empty", else: @ammo_group.count %> |  | ||||||
|         </span> |  | ||||||
|  |  | ||||||
|         <%= if @ammo_group.notes do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Notes:") %> |  | ||||||
|             <%= @ammo_group.notes %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <span class="rounded-lg title text-lg"> |  | ||||||
|           <%= gettext("Added on:") %> |  | ||||||
|           <%= @ammo_group.inserted_at |> display_datetime() %> |  | ||||||
|         </span> |  | ||||||
|  |  | ||||||
|         <%= if @ammo_group.count == 0 do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Used up on:") %> |  | ||||||
|             <%= @ammo_group |> Ammo.get_last_used_shot_group() |> Map.get(:date) |> display_date() %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <%= if @ammo_group.price_paid do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Price paid:") %> |  | ||||||
|             <%= gettext("$%{amount}", |  | ||||||
|               amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2) |  | ||||||
|             ) %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <%= if @show_container and @ammo_group.container do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Container:") %> |  | ||||||
|  |  | ||||||
|             <.link |  | ||||||
|               navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)} |  | ||||||
|               class="link" |  | ||||||
|             > |  | ||||||
|               <%= @ammo_group.container.name %> |  | ||||||
|             </.link> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <%= if assigns |> Map.has_key?(:inner_block) do %> |  | ||||||
|         <div class="mt-4 flex space-x-4 justify-center items-center"> |  | ||||||
|           <%= render_slot(@inner_block) %> |  | ||||||
|         </div> |  | ||||||
|       <% end %> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -1,87 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.ContainerCard do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Display card for a container |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|   import CanneryWeb.Components.TagCard |  | ||||||
|   alias Cannery.{Containers, Containers.Container, Repo} |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|   alias Phoenix.LiveView.Rendered |  | ||||||
|  |  | ||||||
|   attr :container, Container, required: true |  | ||||||
|   slot(:tag_actions) |  | ||||||
|   slot(:inner_block) |  | ||||||
|  |  | ||||||
|   @spec container_card(assigns :: map()) :: Rendered.t() |  | ||||||
|   def container_card(%{container: container} = assigns) do |  | ||||||
|     assigns = |  | ||||||
|       assigns |  | ||||||
|       |> assign(container: container |> Repo.preload([:tags, :ammo_groups])) |  | ||||||
|       |> assign_new(:tag_actions, fn -> [] end) |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <div |  | ||||||
|       id={"container-#{@container.id}"} |  | ||||||
|       class="overflow-hidden max-w-full 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 |  | ||||||
|         transition-all duration-300 ease-in-out" |  | ||||||
|     > |  | ||||||
|       <div class="max-w-full mb-4 flex flex-col justify-center items-center space-y-2"> |  | ||||||
|         <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> |  | ||||||
|           <h1 class="px-4 py-2 rounded-lg title text-xl"> |  | ||||||
|             <%= @container.name %> |  | ||||||
|           </h1> |  | ||||||
|         </.link> |  | ||||||
|  |  | ||||||
|         <%= if @container.desc do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Description:") %> |  | ||||||
|             <%= @container.desc %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <span class="rounded-lg title text-lg"> |  | ||||||
|           <%= gettext("Type:") %> |  | ||||||
|           <%= @container.type %> |  | ||||||
|         </span> |  | ||||||
|  |  | ||||||
|         <%= if @container.location do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Location:") %> |  | ||||||
|             <%= @container.location %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <%= unless @container.ammo_groups |> Enum.empty?() do %> |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Packs:") %> |  | ||||||
|             <%= @container |> Containers.get_container_ammo_group_count!() %> |  | ||||||
|           </span> |  | ||||||
|  |  | ||||||
|           <span class="rounded-lg title text-lg"> |  | ||||||
|             <%= gettext("Rounds:") %> |  | ||||||
|             <%= @container |> Containers.get_container_rounds!() %> |  | ||||||
|           </span> |  | ||||||
|         <% end %> |  | ||||||
|  |  | ||||||
|         <div class="flex flex-wrap justify-center items-center"> |  | ||||||
|           <%= unless @container.tags |> Enum.empty?() do %> |  | ||||||
|             <%= for tag <- @container.tags do %> |  | ||||||
|               <.simple_tag_card tag={tag} /> |  | ||||||
|             <% end %> |  | ||||||
|           <% end %> |  | ||||||
|  |  | ||||||
|           <%= render_slot(@tag_actions) %> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <%= if assigns |> Map.has_key?(:inner_block) do %> |  | ||||||
|         <div class="flex space-x-4 justify-center items-center"> |  | ||||||
|           <%= render_slot(@inner_block) %> |  | ||||||
|         </div> |  | ||||||
|       <% end %> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										161
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | |||||||
|  | defmodule CanneryWeb.Components.ContainerTableComponent do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A component that displays a list of containers | ||||||
|  |   """ | ||||||
|  |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{Accounts.User, Ammo, Containers.Container} | ||||||
|  |   alias Ecto.UUID | ||||||
|  |   alias Phoenix.LiveView.{Rendered, Socket} | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   @spec update( | ||||||
|  |           %{ | ||||||
|  |             required(:id) => UUID.t(), | ||||||
|  |             required(:current_user) => User.t(), | ||||||
|  |             optional(:containers) => [Container.t()], | ||||||
|  |             optional(:tag_actions) => Rendered.t(), | ||||||
|  |             optional(:actions) => Rendered.t(), | ||||||
|  |             optional(any()) => any() | ||||||
|  |           }, | ||||||
|  |           Socket.t() | ||||||
|  |         ) :: {:ok, Socket.t()} | ||||||
|  |   def update(%{id: _id, containers: _containers, current_user: _current_user} = assigns, socket) do | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(assigns) | ||||||
|  |       |> assign_new(:tag_actions, fn -> [] end) | ||||||
|  |       |> assign_new(:actions, fn -> [] end) | ||||||
|  |       |> display_containers() | ||||||
|  |  | ||||||
|  |     {:ok, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp display_containers( | ||||||
|  |          %{ | ||||||
|  |            assigns: %{ | ||||||
|  |              containers: containers, | ||||||
|  |              current_user: current_user, | ||||||
|  |              tag_actions: tag_actions, | ||||||
|  |              actions: actions | ||||||
|  |            } | ||||||
|  |          } = socket | ||||||
|  |        ) do | ||||||
|  |     columns = | ||||||
|  |       [ | ||||||
|  |         %{label: gettext("Name"), key: :name, type: :string}, | ||||||
|  |         %{label: gettext("Description"), key: :desc, type: :string}, | ||||||
|  |         %{label: gettext("Location"), key: :location, type: :string}, | ||||||
|  |         %{label: gettext("Type"), key: :type, type: :string} | ||||||
|  |       ] | ||||||
|  |       |> Enum.filter(fn %{key: key, type: type} -> | ||||||
|  |         # remove columns if all values match defaults | ||||||
|  |         default_value = | ||||||
|  |           case type do | ||||||
|  |             :boolean -> false | ||||||
|  |             _other_type -> nil | ||||||
|  |           end | ||||||
|  |  | ||||||
|  |         containers | ||||||
|  |         |> Enum.any?(fn container -> | ||||||
|  |           type in [:tags, :actions] or not (container |> Map.get(key) == default_value) | ||||||
|  |         end) | ||||||
|  |       end) | ||||||
|  |       |> Enum.concat([ | ||||||
|  |         %{label: gettext("Packs"), key: :packs, 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} | ||||||
|  |       ]) | ||||||
|  |  | ||||||
|  |     extra_data = %{ | ||||||
|  |       current_user: current_user, | ||||||
|  |       tag_actions: tag_actions, | ||||||
|  |       actions: actions, | ||||||
|  |       pack_count: Ammo.get_packs_count_for_containers(containers, current_user), | ||||||
|  |       round_count: Ammo.get_round_count_for_containers(containers, current_user) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     rows = | ||||||
|  |       containers | ||||||
|  |       |> Enum.map(fn container -> | ||||||
|  |         container |> get_row_data_for_container(columns, extra_data) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     socket | ||||||
|  |     |> assign( | ||||||
|  |       columns: columns, | ||||||
|  |       rows: rows | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div id={@id} class="w-full"> | ||||||
|  |       <.live_component | ||||||
|  |         module={CanneryWeb.Components.TableComponent} | ||||||
|  |         id={"table-#{@id}"} | ||||||
|  |         columns={@columns} | ||||||
|  |         rows={@rows} | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map() | ||||||
|  |   defp get_row_data_for_container(container, columns, extra_data) do | ||||||
|  |     columns | ||||||
|  |     |> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @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 | ||||||
|  |     assigns = %{id: id, container_name: container_name} | ||||||
|  |  | ||||||
|  |     {container_name, | ||||||
|  |      ~H""" | ||||||
|  |      <div class="flex flex-wrap justify-center items-center"> | ||||||
|  |        <.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link"> | ||||||
|  |          <%= @container_name %> | ||||||
|  |        </.link> | ||||||
|  |      </div> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:packs, %{id: container_id}, %{pack_count: pack_count}) do | ||||||
|  |     pack_count |> Map.get(container_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do | ||||||
|  |     round_count |> Map.get(container_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do | ||||||
|  |     assigns = %{tag_actions: tag_actions, container: container} | ||||||
|  |  | ||||||
|  |     tag_names = | ||||||
|  |       container.tags | ||||||
|  |       |> Enum.map(fn %{name: name} -> name end) | ||||||
|  |       |> Enum.sort() | ||||||
|  |       |> Enum.join(" ") | ||||||
|  |  | ||||||
|  |     {tag_names, | ||||||
|  |      ~H""" | ||||||
|  |      <div class="flex flex-wrap justify-center items-center"> | ||||||
|  |        <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||||
|  |  | ||||||
|  |        <%= render_slot(@tag_actions, @container) %> | ||||||
|  |      </div> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:actions, container, %{actions: actions}) do | ||||||
|  |     assigns = %{actions: actions, container: container} | ||||||
|  |  | ||||||
|  |     ~H""" | ||||||
|  |     <%= render_slot(@actions, @container) %> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(key, container, _extra_data), do: container |> Map.get(key) | ||||||
|  | end | ||||||
							
								
								
									
										149
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | |||||||
|  | defmodule CanneryWeb.CoreComponents do | ||||||
|  |   @moduledoc """ | ||||||
|  |   Provides core UI components. | ||||||
|  |   """ | ||||||
|  |   use Phoenix.Component | ||||||
|  |   import CanneryWeb.{Gettext, ViewHelpers} | ||||||
|  |   alias Cannery.{Accounts, Accounts.Invite, Accounts.User} | ||||||
|  |   alias Cannery.{Ammo, Ammo.Pack} | ||||||
|  |   alias Cannery.{Containers.Container, Containers.Tag} | ||||||
|  |   alias CanneryWeb.{Endpoint, HomeLive} | ||||||
|  |   alias CanneryWeb.Router.Helpers, as: Routes | ||||||
|  |   alias Phoenix.LiveView.{JS, Rendered} | ||||||
|  |  | ||||||
|  |   embed_templates "core_components/*" | ||||||
|  |  | ||||||
|  |   attr :title_content, :string, default: nil | ||||||
|  |   attr :current_user, User, default: nil | ||||||
|  |  | ||||||
|  |   def topbar(assigns) | ||||||
|  |  | ||||||
|  |   attr :return_to, :string, required: true | ||||||
|  |   slot(:inner_block) | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Renders a live component inside a modal. | ||||||
|  |  | ||||||
|  |   The rendered modal receives a `:return_to` option to properly update | ||||||
|  |   the URL when the modal is closed. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> | ||||||
|  |         <.live_component | ||||||
|  |           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||||
|  |           id={@<%= schema.singular %>.id || :new} | ||||||
|  |           title={@page_title} | ||||||
|  |           action={@live_action} | ||||||
|  |           return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} | ||||||
|  |           <%= schema.singular %>: @<%= schema.singular %> | ||||||
|  |         /> | ||||||
|  |       </.modal> | ||||||
|  |   """ | ||||||
|  |   def modal(assigns) | ||||||
|  |  | ||||||
|  |   defp hide_modal(js \\ %JS{}) do | ||||||
|  |     js | ||||||
|  |     |> JS.hide(to: "#modal", transition: "fade-out") | ||||||
|  |     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||||
|  |     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   attr :action, :string, required: true | ||||||
|  |   attr :value, :boolean, required: true | ||||||
|  |   attr :id, :string, default: nil | ||||||
|  |   slot(:inner_block) | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   A toggle button element that can be directed to a liveview or a | ||||||
|  |   live_component's `handle_event/3`. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||||
|  |     <span>Toggle me!</span> | ||||||
|  |   </.toggle_button> | ||||||
|  |   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||||
|  |     <span>Whatever you want</span> | ||||||
|  |   </.toggle_button> | ||||||
|  |   """ | ||||||
|  |   def toggle_button(assigns) | ||||||
|  |  | ||||||
|  |   attr :container, Container, required: true | ||||||
|  |   attr :current_user, User, required: true | ||||||
|  |   slot(:tag_actions) | ||||||
|  |   slot(:inner_block) | ||||||
|  |  | ||||||
|  |   @spec container_card(assigns :: map()) :: Rendered.t() | ||||||
|  |   def container_card(assigns) | ||||||
|  |  | ||||||
|  |   attr :tag, Tag, required: true | ||||||
|  |   slot(:inner_block, required: true) | ||||||
|  |  | ||||||
|  |   def tag_card(assigns) | ||||||
|  |  | ||||||
|  |   attr :tag, Tag, required: true | ||||||
|  |  | ||||||
|  |   def simple_tag_card(assigns) | ||||||
|  |  | ||||||
|  |   attr :pack, Pack, required: true | ||||||
|  |   attr :current_user, User, required: true | ||||||
|  |   attr :original_count, :integer, default: nil | ||||||
|  |   attr :cpr, :integer, default: nil | ||||||
|  |   attr :last_used_date, Date, default: nil | ||||||
|  |   attr :container, Container, default: nil | ||||||
|  |   slot(:inner_block) | ||||||
|  |  | ||||||
|  |   def pack_card(assigns) | ||||||
|  |  | ||||||
|  |   @spec display_currency(float()) :: String.t() | ||||||
|  |   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||||
|  |  | ||||||
|  |   attr :user, User, required: true | ||||||
|  |   slot(:inner_block, required: true) | ||||||
|  |  | ||||||
|  |   def user_card(assigns) | ||||||
|  |  | ||||||
|  |   attr :invite, Invite, required: true | ||||||
|  |   attr :use_count, :integer, default: nil | ||||||
|  |   attr :current_user, User, required: true | ||||||
|  |   slot(:inner_block) | ||||||
|  |   slot(:code_actions) | ||||||
|  |  | ||||||
|  |   def invite_card(assigns) | ||||||
|  |  | ||||||
|  |   attr :content, :string, required: true | ||||||
|  |   attr :filename, :string, default: "qrcode", doc: "filename without .png extension" | ||||||
|  |   attr :image_class, :string, default: "w-64 h-max" | ||||||
|  |   attr :width, :integer, default: 384, doc: "width of png to generate" | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Creates a downloadable QR Code element | ||||||
|  |   """ | ||||||
|  |   def qr_code(assigns) | ||||||
|  |  | ||||||
|  |   attr :id, :string, required: true | ||||||
|  |   attr :date, :any, required: true, doc: "A `Date` struct or nil" | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Phoenix.Component for a <date> element that renders the Date in the user's | ||||||
|  |   local timezone | ||||||
|  |   """ | ||||||
|  |   def date(assigns) | ||||||
|  |  | ||||||
|  |   attr :id, :string, required: true | ||||||
|  |   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Phoenix.Component for a <time> element that renders the naivedatetime in the | ||||||
|  |   user's local timezone | ||||||
|  |   """ | ||||||
|  |   def datetime(assigns) | ||||||
|  |  | ||||||
|  |   @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() | ||||||
|  |   defp cast_datetime(%NaiveDateTime{} = datetime) do | ||||||
|  |     datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp cast_datetime(_datetime), do: "" | ||||||
|  | end | ||||||
| @@ -0,0 +1,58 @@ | |||||||
|  | <div | ||||||
|  |   id={"container-#{@container.id}"} | ||||||
|  |   class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 | ||||||
|  |     flex flex-col justify-around items-center space-y-4 | ||||||
|  |     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|  |     transition-all duration-300 ease-in-out" | ||||||
|  | > | ||||||
|  |   <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||||
|  |     <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||||
|  |       <%= @container.name %> | ||||||
|  |     </h1> | ||||||
|  |   </.link> | ||||||
|  |  | ||||||
|  |   <div class="flex flex-col justify-center items-center space-y-2"> | ||||||
|  |     <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||||
|  |       <%= gettext("Description:") %> | ||||||
|  |       <%= @container.desc %> | ||||||
|  |     </span> | ||||||
|  |  | ||||||
|  |     <span class="rounded-lg title text-lg"> | ||||||
|  |       <%= gettext("Type:") %> | ||||||
|  |       <%= @container.type %> | ||||||
|  |     </span> | ||||||
|  |  | ||||||
|  |     <span :if={@container.location} class="rounded-lg title text-lg"> | ||||||
|  |       <%= gettext("Location:") %> | ||||||
|  |       <%= @container.location %> | ||||||
|  |     </span> | ||||||
|  |  | ||||||
|  |     <%= if @container |> Ammo.get_packs_count_for_container!(@current_user) != 0 do %> | ||||||
|  |       <span class="rounded-lg title text-lg"> | ||||||
|  |         <%= gettext("Packs:") %> | ||||||
|  |         <%= @container |> Ammo.get_packs_count_for_container!(@current_user) %> | ||||||
|  |       </span> | ||||||
|  |  | ||||||
|  |       <span class="rounded-lg title text-lg"> | ||||||
|  |         <%= gettext("Rounds:") %> | ||||||
|  |         <%= @container |> Ammo.get_round_count_for_container!(@current_user) %> | ||||||
|  |       </span> | ||||||
|  |     <% end %> | ||||||
|  |  | ||||||
|  |     <div | ||||||
|  |       :if={@tag_actions || @container.tags != []} | ||||||
|  |       class="flex flex-wrap justify-center items-center" | ||||||
|  |     > | ||||||
|  |       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||||
|  |  | ||||||
|  |       <%= if @tag_actions, do: render_slot(@tag_actions) %> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div | ||||||
|  |     :if={assigns |> Map.has_key?(:inner_block)} | ||||||
|  |     class="flex space-x-4 justify-center items-center" | ||||||
|  |   > | ||||||
|  |     <%= render_slot(@inner_block) %> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | ||||||
|  |   <%= Date.to_iso8601(@date, :extended) %> | ||||||
|  | </time> | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | ||||||
|  |   <%= cast_datetime(@datetime) %> | ||||||
|  | </time> | ||||||
| @@ -0,0 +1,46 @@ | |||||||
|  | <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 | ||||||
|  |   transition-all duration-300 ease-in-out"> | ||||||
|  |   <h1 class="title text-xl"> | ||||||
|  |     <%= @invite.name %> | ||||||
|  |   </h1> | ||||||
|  |  | ||||||
|  |   <%= if @invite.disabled_at |> is_nil() do %> | ||||||
|  |     <h2 class="title text-md"> | ||||||
|  |       <%= if @invite.uses_left do %> | ||||||
|  |         <%= gettext( | ||||||
|  |           "Uses Left: %{uses_left_count}", | ||||||
|  |           uses_left_count: @invite.uses_left | ||||||
|  |         ) %> | ||||||
|  |       <% else %> | ||||||
|  |         <%= gettext("Uses Left: Unlimited") %> | ||||||
|  |       <% end %> | ||||||
|  |     </h2> | ||||||
|  |   <% else %> | ||||||
|  |     <h2 class="title text-md"> | ||||||
|  |       <%= gettext("Invite Disabled") %> | ||||||
|  |     </h2> | ||||||
|  |   <% end %> | ||||||
|  |  | ||||||
|  |   <.qr_code | ||||||
|  |     content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||||
|  |     filename={@invite.name} | ||||||
|  |   /> | ||||||
|  |  | ||||||
|  |   <h2 :if={@use_count && @use_count != 0} class="title text-md"> | ||||||
|  |     <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> | ||||||
|  |   </h2> | ||||||
|  |  | ||||||
|  |   <div class="flex flex-row flex-wrap justify-center items-center"> | ||||||
|  |     <code | ||||||
|  |       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" | ||||||
|  |       phx-no-format | ||||||
|  |     ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||||
|  |     <%= if @code_actions, do: render_slot(@code_actions) %> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||||
|  |     <%= render_slot(@inner_block) %> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | <.link | ||||||
|  |   patch={@return_to} | ||||||
|  |   id="modal-bg" | ||||||
|  |   class="fade-in fixed z-10 left-0 top-0 | ||||||
|  |     w-full h-full overflow-hidden | ||||||
|  |     p-8 flex flex-col justify-center items-center cursor-auto" | ||||||
|  |   style="background-color: rgba(0,0,0,0.4);" | ||||||
|  |   phx-remove={hide_modal()} | ||||||
|  |   aria-label={gettext("Close modal")} | ||||||
|  | > | ||||||
|  |   <span class="hidden"></span> | ||||||
|  | </.link> | ||||||
|  |  | ||||||
|  | <div | ||||||
|  |   id="modal" | ||||||
|  |   class="fixed z-10 left-0 top-0 pointer-events-none | ||||||
|  |     w-full h-full overflow-hidden | ||||||
|  |     p-4 sm:p-8 flex flex-col justify-center items-center" | ||||||
|  | > | ||||||
|  |   <div | ||||||
|  |     id="modal-content" | ||||||
|  |     class="fade-in-scale w-full max-w-3xl relative | ||||||
|  |       pointer-events-auto overflow-hidden | ||||||
|  |       px-8 py-4 sm:py-8 | ||||||
|  |       flex flex-col justify-start items-center | ||||||
|  |       bg-white border-2 rounded-lg" | ||||||
|  |   > | ||||||
|  |     <.link | ||||||
|  |       patch={@return_to} | ||||||
|  |       id="close" | ||||||
|  |       class="absolute top-8 right-10 | ||||||
|  |         text-gray-500 hover:text-gray-800 | ||||||
|  |         transition-all duration-500 ease-in-out" | ||||||
|  |       phx-remove={hide_modal()} | ||||||
|  |       aria-label={gettext("Close modal")} | ||||||
|  |     > | ||||||
|  |       <i class="fa-fw fa-lg fas fa-times"></i> | ||||||
|  |     </.link> | ||||||
|  |  | ||||||
|  |     <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) %> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -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-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|  |     transition-all duration-300 ease-in-out" | ||||||
|  | > | ||||||
|  |   <.link navigate={Routes.pack_show_path(Endpoint, :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={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> | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | <a href={qr_code_image(@content)} download={@filename <> ".png"}> | ||||||
|  |   <img class={@image_class} alt={@filename} src={qr_code_image(@content)} /> | ||||||
|  | </a> | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | <h1 | ||||||
|  |   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}"} | ||||||
|  | > | ||||||
|  |   <%= @tag.name %> | ||||||
|  | </h1> | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | <div | ||||||
|  |   id={"tag-#{@tag.id}"} | ||||||
|  |   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 | ||||||
|  |     transition-all duration-300 ease-in-out" | ||||||
|  | > | ||||||
|  |   <.simple_tag_card tag={@tag} /> | ||||||
|  |   <%= render_slot(@inner_block) %> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,30 @@ | |||||||
|  | <label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> | ||||||
|  |   <input | ||||||
|  |     id={@id || @action} | ||||||
|  |     type="checkbox" | ||||||
|  |     value={@value} | ||||||
|  |     checked={@value} | ||||||
|  |     class="sr-only peer" | ||||||
|  |     aria-labelledby={"#{@id || @action}-label"} | ||||||
|  |     { | ||||||
|  |       if assigns |> Map.has_key?(:target), | ||||||
|  |         do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||||
|  |         else: %{"phx-click": @action, "phx-value-value": @value} | ||||||
|  |     } | ||||||
|  |   /> | ||||||
|  |   <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||||
|  |     peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||||
|  |     peer-checked:bg-gray-600 | ||||||
|  |     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:transition-all after:duration-250 after:ease-in-out | ||||||
|  |     transition-colors duration-250 ease-in-out"> | ||||||
|  |   </div> | ||||||
|  |   <span | ||||||
|  |     id={"#{@id || @action}-label"} | ||||||
|  |     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" | ||||||
|  |   > | ||||||
|  |     <%= render_slot(@inner_block) %> | ||||||
|  |   </span> | ||||||
|  | </label> | ||||||
							
								
								
									
										130
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | |||||||
|  | <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="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||||
|  |       <.link | ||||||
|  |         navigate={Routes.live_path(Endpoint, HomeLive)} | ||||||
|  |         class="inline mx-2 my-1 leading-5 text-xl text-white" | ||||||
|  |       > | ||||||
|  |         <img | ||||||
|  |           src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||||
|  |           alt={gettext("Cannery logo")} | ||||||
|  |           class="inline-block h-8 mx-1" | ||||||
|  |         /> | ||||||
|  |         <h1 class="inline hover:underline">Cannery</h1> | ||||||
|  |       </.link> | ||||||
|  |  | ||||||
|  |       <%= if @title_content do %> | ||||||
|  |         <span class="mx-2 my-1"> | ||||||
|  |           | | ||||||
|  |         </span> | ||||||
|  |         <%= @title_content %> | ||||||
|  |       <% end %> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <hr class="mb-2 sm:hidden hr-light" /> | ||||||
|  |  | ||||||
|  |     <ul class="flex flex-row flex-wrap justify-center items-center | ||||||
|  |       text-lg text-white text-ellipsis"> | ||||||
|  |       <%= if @current_user do %> | ||||||
|  |         <li class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             navigate={Routes.tag_index_path(Endpoint, :index)} | ||||||
|  |             class="text-white hover:underline" | ||||||
|  |           > | ||||||
|  |             <%= gettext("Tags") %> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             navigate={Routes.container_index_path(Endpoint, :index)} | ||||||
|  |             class="text-white hover:underline" | ||||||
|  |           > | ||||||
|  |             <%= gettext("Containers") %> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             navigate={Routes.type_index_path(Endpoint, :index)} | ||||||
|  |             class="text-white hover:underline" | ||||||
|  |           > | ||||||
|  |             <%= gettext("Catalog") %> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             navigate={Routes.pack_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" | ||||||
|  |             data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||||
|  |             aria-label={gettext("Log out")} | ||||||
|  |           > | ||||||
|  |             <i class="fas fa-sign-out-alt"></i> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li | ||||||
|  |           :if={ | ||||||
|  |             @current_user |> Accounts.is_already_admin?() and | ||||||
|  |               function_exported?(Routes, :live_dashboard_path, 2) | ||||||
|  |           } | ||||||
|  |           class="mx-2 my-1" | ||||||
|  |         > | ||||||
|  |           <.link | ||||||
|  |             navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||||
|  |             class="text-white hover:underline" | ||||||
|  |             aria-label={gettext("Live Dashboard")} | ||||||
|  |           > | ||||||
|  |             <i class="fas fa-gauge"></i> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |       <% else %> | ||||||
|  |         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             href={Routes.user_registration_path(Endpoint, :new)} | ||||||
|  |             class="text-white hover:underline truncate" | ||||||
|  |           > | ||||||
|  |             <%= dgettext("actions", "Register") %> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li class="mx-2 my-1"> | ||||||
|  |           <.link | ||||||
|  |             href={Routes.user_session_path(Endpoint, :new)} | ||||||
|  |             class="text-white hover:underline truncate" | ||||||
|  |           > | ||||||
|  |             <%= dgettext("actions", "Log in") %> | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |       <% end %> | ||||||
|  |     </ul> | ||||||
|  |   </div> | ||||||
|  | </nav> | ||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | <div | ||||||
|  |   id={"user-#{@user.id}"} | ||||||
|  |   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 | ||||||
|  |     transition-all duration-300 ease-in-out" | ||||||
|  | > | ||||||
|  |   <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||||
|  |     <%= @user.email %> | ||||||
|  |   </h1> | ||||||
|  |  | ||||||
|  |   <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||||
|  |     <p> | ||||||
|  |       <%= if @user.confirmed_at do %> | ||||||
|  |         <%= gettext( | ||||||
|  |           "User was confirmed at%{confirmed_datetime}", | ||||||
|  |           confirmed_datetime: "" | ||||||
|  |         ) %> | ||||||
|  |         <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> | ||||||
|  |       <% else %> | ||||||
|  |         <%= gettext("Email unconfirmed") %> | ||||||
|  |       <% end %> | ||||||
|  |     </p> | ||||||
|  |  | ||||||
|  |     <p> | ||||||
|  |       <%= gettext( | ||||||
|  |         "User registered on%{registered_datetime}", | ||||||
|  |         registered_datetime: "" | ||||||
|  |       ) %> | ||||||
|  |       <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> | ||||||
|  |     </p> | ||||||
|  |   </h3> | ||||||
|  |  | ||||||
|  |   <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||||
|  |     <%= render_slot(@inner_block) %> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -1,55 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.InviteCard do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Display card for an invite |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|   alias Cannery.Invites.Invite |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|  |  | ||||||
|   attr :invite, Invite, required: true |  | ||||||
|   slot(:inner_block) |  | ||||||
|   slot(:code_actions) |  | ||||||
|  |  | ||||||
|   def invite_card(assigns) do |  | ||||||
|     assigns = assigns |> assign_new(:code_actions, fn -> [] end) |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <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 |  | ||||||
|       transition-all duration-300 ease-in-out"> |  | ||||||
|       <h1 class="title text-xl"> |  | ||||||
|         <%= @invite.name %> |  | ||||||
|       </h1> |  | ||||||
|  |  | ||||||
|       <%= if @invite.disabled_at |> is_nil() do %> |  | ||||||
|         <h2 class="title text-md"> |  | ||||||
|           <%= gettext("Uses Left:") %> |  | ||||||
|           <%= @invite.uses_left || "Unlimited" %> |  | ||||||
|         </h2> |  | ||||||
|       <% else %> |  | ||||||
|         <h2 class="title text-md"> |  | ||||||
|           <%= gettext("Invite Disabled") %> |  | ||||||
|         </h2> |  | ||||||
|       <% end %> |  | ||||||
|  |  | ||||||
|       <div class="flex flex-row flex-wrap justify-center items-center"> |  | ||||||
|         <code |  | ||||||
|           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" |  | ||||||
|         > |  | ||||||
|           <%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %> |  | ||||||
|         </code> |  | ||||||
|  |  | ||||||
|         <%= render_slot(@code_actions) %> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <%= if @inner_block do %> |  | ||||||
|         <div class="flex space-x-4 justify-center items-center"> |  | ||||||
|           <%= render_slot(@inner_block) %> |  | ||||||
|         </div> |  | ||||||
|       <% end %> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -1,28 +1,28 @@ | |||||||
| 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 CanneryWeb.Endpoint | ||||||
|  |   alias Ecto.Changeset | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
| 
 | 
 | ||||||
|   @impl true |   @impl true | ||||||
|   @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) | ||||||
| @@ -40,21 +40,19 @@ 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) | ||||||
| 
 | 
 | ||||||
|         {:error, %Ecto.Changeset{} = changeset} -> |         {:error, %Changeset{} = changeset} -> | ||||||
|           socket |> assign(changeset: changeset) |           socket |> assign(changeset: changeset) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
| @@ -64,10 +62,10 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | |||||||
|   @impl true |   @impl true | ||||||
|   def render(%{containers: containers} = assigns) do |   def render(%{containers: containers} = assigns) do | ||||||
|     columns = [ |     columns = [ | ||||||
|       %{label: gettext("Container"), key: "name"}, |       %{label: gettext("Container"), key: :name}, | ||||||
|       %{label: gettext("Type"), key: "type"}, |       %{label: gettext("Type"), key: :type}, | ||||||
|       %{label: gettext("Location"), key: "location"}, |       %{label: gettext("Location"), key: :location}, | ||||||
|       %{label: nil, key: "actions", sortable: false} |       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     rows = containers |> get_rows_for_containers(assigns, columns) |     rows = containers |> get_rows_for_containers(assigns, columns) | ||||||
| @@ -92,7 +90,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | |||||||
|       <% 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} | ||||||
|         /> |         /> | ||||||
| @@ -106,12 +104,12 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | |||||||
|     containers |     containers | ||||||
|     |> Enum.map(fn container -> |     |> Enum.map(fn container -> | ||||||
|       columns |       columns | ||||||
|       |> Enum.into(%{}, fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end) |       |> Map.new(fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end) | ||||||
|     end) |     end) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   @spec get_row_value_by_key(String.t(), Container.t(), map()) :: any() |   @spec get_row_value_by_key(atom(), Container.t(), map()) :: any() | ||||||
|   defp get_row_value_by_key("actions", container, assigns) do |   defp get_row_value_by_key(:actions, container, assigns) do | ||||||
|     assigns = assigns |> Map.put(:container, container) |     assigns = assigns |> Map.put(:container, container) | ||||||
| 
 | 
 | ||||||
|     ~H""" |     ~H""" | ||||||
| @@ -129,6 +127,5 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | |||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   defp get_row_value_by_key(key, container, _assigns), |   defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key) | ||||||
|     do: container |> Map.get(key |> String.to_existing_atom()) |  | ||||||
| end | end | ||||||
							
								
								
									
										269
									
								
								lib/cannery_web/components/pack_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								lib/cannery_web/components/pack_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | |||||||
|  | defmodule CanneryWeb.Components.PackTableComponent do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A component that displays a list of packs | ||||||
|  |   """ | ||||||
|  |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{Accounts.User, Ammo.Pack, ComparableDate} | ||||||
|  |   alias Cannery.{ActivityLog, Ammo, Containers} | ||||||
|  |   alias CanneryWeb.Components.TableComponent | ||||||
|  |   alias Ecto.UUID | ||||||
|  |   alias Phoenix.LiveView.{Rendered, Socket} | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   @spec update( | ||||||
|  |           %{ | ||||||
|  |             required(:id) => UUID.t(), | ||||||
|  |             required(:current_user) => User.t(), | ||||||
|  |             required(:packs) => [Pack.t()], | ||||||
|  |             required(:show_used) => boolean(), | ||||||
|  |             optional(:type) => Rendered.t(), | ||||||
|  |             optional(:range) => Rendered.t(), | ||||||
|  |             optional(:container) => Rendered.t(), | ||||||
|  |             optional(:actions) => Rendered.t(), | ||||||
|  |             optional(any()) => any() | ||||||
|  |           }, | ||||||
|  |           Socket.t() | ||||||
|  |         ) :: {:ok, Socket.t()} | ||||||
|  |   def update( | ||||||
|  |         %{id: _id, packs: _pack, current_user: _current_user, show_used: _show_used} = assigns, | ||||||
|  |         socket | ||||||
|  |       ) do | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(assigns) | ||||||
|  |       |> assign_new(:type, fn -> [] end) | ||||||
|  |       |> assign_new(:range, fn -> [] end) | ||||||
|  |       |> assign_new(:container, fn -> [] end) | ||||||
|  |       |> assign_new(:actions, fn -> [] end) | ||||||
|  |       |> display_packs() | ||||||
|  |  | ||||||
|  |     {:ok, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp display_packs( | ||||||
|  |          %{ | ||||||
|  |            assigns: %{ | ||||||
|  |              packs: packs, | ||||||
|  |              current_user: current_user, | ||||||
|  |              type: type, | ||||||
|  |              range: range, | ||||||
|  |              container: container, | ||||||
|  |              actions: actions, | ||||||
|  |              show_used: show_used | ||||||
|  |            } | ||||||
|  |          } = socket | ||||||
|  |        ) 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 = | ||||||
|  |       [] | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Actions"), key: :actions, sortable: false}, | ||||||
|  |         actions != [] | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: gettext("Last used on"), | ||||||
|  |         key: :used_up_on, | ||||||
|  |         type: ComparableDate | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: gettext("Purchased on"), | ||||||
|  |         key: :purchased_on, | ||||||
|  |         type: ComparableDate | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Container"), key: :container}, | ||||||
|  |         container != [] | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Range"), key: :range}, | ||||||
|  |         range != [] | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{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( | ||||||
|  |         %{label: gettext("% left"), key: :remaining}, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Original Count"), key: :original_count}, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: if(show_used, do: gettext("Current Count"), else: gettext("Count")), | ||||||
|  |         key: :count | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Type"), key: :type}, | ||||||
|  |         type != [] | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |     containers = | ||||||
|  |       packs | ||||||
|  |       |> Enum.map(fn %{container_id: container_id} -> container_id end) | ||||||
|  |       |> Containers.get_containers(current_user) | ||||||
|  |  | ||||||
|  |     extra_data = %{ | ||||||
|  |       current_user: current_user, | ||||||
|  |       type: type, | ||||||
|  |       columns: columns, | ||||||
|  |       container: container, | ||||||
|  |       containers: containers, | ||||||
|  |       original_counts: Ammo.get_original_counts(packs, current_user), | ||||||
|  |       cprs: Ammo.get_cprs(packs, current_user), | ||||||
|  |       last_used_dates: ActivityLog.get_last_used_dates(packs, current_user), | ||||||
|  |       percentages_remaining: Ammo.get_percentages_remaining(packs, current_user), | ||||||
|  |       actions: actions, | ||||||
|  |       range: range | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     rows = | ||||||
|  |       packs | ||||||
|  |       |> Enum.map(fn pack -> | ||||||
|  |         pack |> get_row_data_for_pack(extra_data) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     socket |> assign(columns: columns, rows: rows) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div id={@id} class="w-full"> | ||||||
|  |       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_row_data_for_pack(Pack.t(), additional_data :: map()) :: map() | ||||||
|  |   defp get_row_data_for_pack(pack, %{columns: columns} = additional_data) do | ||||||
|  |     columns | ||||||
|  |     |> Map.new(fn %{key: key} -> | ||||||
|  |       {key, get_value_for_key(key, pack, additional_data)} | ||||||
|  |     end) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_value_for_key(atom(), Pack.t(), additional_data :: map()) :: | ||||||
|  |           any() | {any(), Rendered.t()} | ||||||
|  |   defp get_value_for_key( | ||||||
|  |          :type, | ||||||
|  |          %{type: %{name: type_name} = type}, | ||||||
|  |          %{type: type_block} | ||||||
|  |        ) do | ||||||
|  |     assigns = %{type: type, type_block: type_block} | ||||||
|  |  | ||||||
|  |     {type_name, | ||||||
|  |      ~H""" | ||||||
|  |      <%= render_slot(@type_block, @type) %> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), | ||||||
|  |     do: {0, gettext("No cost information")} | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), | ||||||
|  |     do: {price_paid, gettext("$%{amount}", amount: display_currency(price_paid))} | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do | ||||||
|  |     {purchased_on, | ||||||
|  |      ~H""" | ||||||
|  |      <.date id={"#{@id}-purchased-on"} date={@purchased_on} /> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   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(pack_id) | ||||||
|  |     assigns = %{id: pack_id, last_used_date: last_used_date} | ||||||
|  |  | ||||||
|  |     {last_used_date, | ||||||
|  |      ~H""" | ||||||
|  |      <%= if @last_used_date do %> | ||||||
|  |        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> | ||||||
|  |      <% else %> | ||||||
|  |        <%= gettext("Never used") %> | ||||||
|  |      <% end %> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do | ||||||
|  |     assigns = %{range: range, pack: pack} | ||||||
|  |  | ||||||
|  |     {staged, | ||||||
|  |      ~H""" | ||||||
|  |      <%= render_slot(@range, @pack) %> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key( | ||||||
|  |          :remaining, | ||||||
|  |          %{id: pack_id}, | ||||||
|  |          %{percentages_remaining: percentages_remaining} | ||||||
|  |        ) do | ||||||
|  |     percentage = Map.fetch!(percentages_remaining, pack_id) | ||||||
|  |     {percentage, gettext("%{percentage}%", percentage: percentage)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:actions, pack, %{actions: actions}) do | ||||||
|  |     assigns = %{actions: actions, pack: pack} | ||||||
|  |  | ||||||
|  |     ~H""" | ||||||
|  |     <%= render_slot(@actions, @pack) %> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil} | ||||||
|  |  | ||||||
|  |   defp get_value_for_key( | ||||||
|  |          :container, | ||||||
|  |          %{container_id: container_id} = pack, | ||||||
|  |          %{container: container_block, containers: containers} | ||||||
|  |        ) do | ||||||
|  |     container = %{name: container_name} = Map.fetch!(containers, container_id) | ||||||
|  |  | ||||||
|  |     assigns = %{ | ||||||
|  |       container: container, | ||||||
|  |       container_block: container_block, | ||||||
|  |       pack: pack | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     {container_name, | ||||||
|  |      ~H""" | ||||||
|  |      <%= render_slot(@container_block, {@pack, @container}) %> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key( | ||||||
|  |          :original_count, | ||||||
|  |          %{id: pack_id}, | ||||||
|  |          %{original_counts: original_counts} | ||||||
|  |        ) do | ||||||
|  |     Map.fetch!(original_counts, pack_id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), | ||||||
|  |     do: {0, gettext("No cost information")} | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:cpr, %{id: pack_id}, %{cprs: cprs}) do | ||||||
|  |     amount = Map.fetch!(cprs, pack_id) | ||||||
|  |     {amount, gettext("$%{amount}", amount: display_currency(amount))} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:count, %{count: count}, _additional_data), | ||||||
|  |     do: if(count == 0, do: {0, gettext("Empty")}, else: count) | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(key, pack, _additional_data), do: pack |> Map.get(key) | ||||||
|  |  | ||||||
|  |   @spec display_currency(float()) :: String.t() | ||||||
|  |   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||||
|  | end | ||||||
							
								
								
									
										123
									
								
								lib/cannery_web/components/shot_record_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								lib/cannery_web/components/shot_record_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | |||||||
|  | defmodule CanneryWeb.Components.ShotRecordTableComponent do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A component that displays a list of shot records | ||||||
|  |   """ | ||||||
|  |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Ammo, ComparableDate} | ||||||
|  |   alias Ecto.UUID | ||||||
|  |   alias Phoenix.LiveView.{Rendered, Socket} | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   @spec update( | ||||||
|  |           %{ | ||||||
|  |             required(:id) => UUID.t(), | ||||||
|  |             required(:current_user) => User.t(), | ||||||
|  |             optional(:shot_records) => [ShotRecord.t()], | ||||||
|  |             optional(:actions) => Rendered.t(), | ||||||
|  |             optional(any()) => any() | ||||||
|  |           }, | ||||||
|  |           Socket.t() | ||||||
|  |         ) :: {:ok, Socket.t()} | ||||||
|  |   def update( | ||||||
|  |         %{id: _id, shot_records: _shot_records, current_user: _current_user} = assigns, | ||||||
|  |         socket | ||||||
|  |       ) do | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(assigns) | ||||||
|  |       |> assign_new(:actions, fn -> [] end) | ||||||
|  |       |> display_shot_records() | ||||||
|  |  | ||||||
|  |     {:ok, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp display_shot_records( | ||||||
|  |          %{ | ||||||
|  |            assigns: %{ | ||||||
|  |              shot_records: shot_records, | ||||||
|  |              current_user: current_user, | ||||||
|  |              actions: actions | ||||||
|  |            } | ||||||
|  |          } = socket | ||||||
|  |        ) do | ||||||
|  |     columns = [ | ||||||
|  |       %{label: gettext("Ammo"), key: :name}, | ||||||
|  |       %{label: gettext("Rounds shot"), key: :count}, | ||||||
|  |       %{label: gettext("Notes"), key: :notes}, | ||||||
|  |       %{label: gettext("Date"), key: :date, type: ComparableDate}, | ||||||
|  |       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     packs = | ||||||
|  |       shot_records | ||||||
|  |       |> Enum.map(fn %{pack_id: pack_id} -> pack_id end) | ||||||
|  |       |> Ammo.get_packs(current_user) | ||||||
|  |  | ||||||
|  |     extra_data = %{current_user: current_user, actions: actions, packs: packs} | ||||||
|  |  | ||||||
|  |     rows = | ||||||
|  |       shot_records | ||||||
|  |       |> Enum.map(fn shot_record -> | ||||||
|  |         shot_record |> get_row_data_for_shot_record(columns, extra_data) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     socket | ||||||
|  |     |> assign( | ||||||
|  |       columns: columns, | ||||||
|  |       rows: rows | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div id={@id} class="w-full"> | ||||||
|  |       <.live_component | ||||||
|  |         module={CanneryWeb.Components.TableComponent} | ||||||
|  |         id={"table-#{@id}"} | ||||||
|  |         columns={@columns} | ||||||
|  |         rows={@rows} | ||||||
|  |         initial_key={:date} | ||||||
|  |         initial_sort_mode={:desc} | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_row_data_for_shot_record(ShotRecord.t(), columns :: [map()], extra_data :: map()) :: | ||||||
|  |           map() | ||||||
|  |   defp get_row_data_for_shot_record(shot_record, columns, extra_data) do | ||||||
|  |     columns | ||||||
|  |     |> Map.new(fn %{key: key} -> | ||||||
|  |       {key, get_row_value(key, shot_record, extra_data)} | ||||||
|  |     end) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_row_value(:name, %{pack_id: pack_id}, %{packs: packs}) do | ||||||
|  |     assigns = %{pack: pack = Map.fetch!(packs, pack_id)} | ||||||
|  |  | ||||||
|  |     {pack.type.name, | ||||||
|  |      ~H""" | ||||||
|  |      <.link navigate={Routes.pack_show_path(Endpoint, :show, @pack)} class="link"> | ||||||
|  |        <%= @pack.type.name %> | ||||||
|  |      </.link> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_row_value(:date, %{date: date} = assigns, _extra_data) do | ||||||
|  |     {date, | ||||||
|  |      ~H""" | ||||||
|  |      <.date id={"#{@id}-date"} date={@date} /> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_row_value(:actions, shot_record, %{actions: actions}) do | ||||||
|  |     assigns = %{actions: actions, shot_record: shot_record} | ||||||
|  |  | ||||||
|  |     ~H""" | ||||||
|  |     <%= render_slot(@actions, @shot_record) %> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_row_value(key, shot_record, _extra_data), do: shot_record |> Map.get(key) | ||||||
|  | end | ||||||
| @@ -21,6 +21,7 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |   use CanneryWeb, :live_component | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
|  |   require Integer | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   @spec update( |   @spec update( | ||||||
| @@ -30,7 +31,10 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|                 required(:label) => String.t() | nil, |                 required(:label) => String.t() | nil, | ||||||
|                 required(:key) => atom() | nil, |                 required(:key) => atom() | nil, | ||||||
|                 optional(:class) => String.t(), |                 optional(:class) => String.t(), | ||||||
|                 optional(:sortable) => false |                 optional(:row_class) => String.t(), | ||||||
|  |                 optional(:alternate_row_class) => String.t(), | ||||||
|  |                 optional(:sortable) => false, | ||||||
|  |                 optional(:type) => module() | ||||||
|               }), |               }), | ||||||
|             required(:rows) => |             required(:rows) => | ||||||
|               list(%{ |               list(%{ | ||||||
| @@ -57,7 +61,8 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|         :asc |         :asc | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     rows = rows |> Enum.sort_by(fn row -> row |> Map.get(initial_key) end, initial_sort_mode) |     type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type) | ||||||
|  |     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type) | ||||||
|  |  | ||||||
|     socket = |     socket = | ||||||
|       socket |       socket | ||||||
| @@ -65,9 +70,12 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|       |> assign( |       |> assign( | ||||||
|         columns: columns, |         columns: columns, | ||||||
|         rows: rows, |         rows: rows, | ||||||
|  |         key: initial_key, | ||||||
|         last_sort_key: initial_key, |         last_sort_key: initial_key, | ||||||
|         sort_mode: initial_sort_mode |         sort_mode: initial_sort_mode | ||||||
|       ) |       ) | ||||||
|  |       |> assign_new(:row_class, fn -> "bg-white" end) | ||||||
|  |       |> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) | ||||||
|  |  | ||||||
|     {:ok, socket} |     {:ok, socket} | ||||||
|   end |   end | ||||||
| @@ -76,7 +84,14 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|   def handle_event( |   def handle_event( | ||||||
|         "sort_by", |         "sort_by", | ||||||
|         %{"sort-key" => key}, |         %{"sort-key" => key}, | ||||||
|         %{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket |         %{ | ||||||
|  |           assigns: %{ | ||||||
|  |             columns: columns, | ||||||
|  |             rows: rows, | ||||||
|  |             last_sort_key: last_sort_key, | ||||||
|  |             sort_mode: sort_mode | ||||||
|  |           } | ||||||
|  |         } = socket | ||||||
|       ) do |       ) do | ||||||
|     key = key |> String.to_existing_atom() |     key = key |> String.to_existing_atom() | ||||||
|  |  | ||||||
| @@ -87,11 +102,28 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|         {_new_sort_key, _last_sort_mode} -> :asc |         {_new_sort_key, _last_sort_mode} -> :asc | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) |     type = | ||||||
|  |       columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type) | ||||||
|  |  | ||||||
|  |     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type) | ||||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} |     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do |   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) | ||||||
|  |        when type in [Date, DateTime] do | ||||||
|  |     rows | ||||||
|  |     |> Enum.sort_by( | ||||||
|  |       fn row -> | ||||||
|  |         case row |> Map.get(key) do | ||||||
|  |           {custom_sort_key, _value} -> custom_sort_key | ||||||
|  |           value -> value | ||||||
|  |         end | ||||||
|  |       end, | ||||||
|  |       {sort_mode, type} | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do | ||||||
|     rows |     rows | ||||||
|     |> Enum.sort_by( |     |> Enum.sort_by( | ||||||
|       fn row -> |       fn row -> | ||||||
| @@ -103,4 +135,25 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|       sort_mode |       sort_mode | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Conditionally composes elements into the columns list, supports maps and | ||||||
|  |   lists. Works tail to front in order for efficiency | ||||||
|  |  | ||||||
|  |       iex> [] | ||||||
|  |       ...> |> maybe_compose_columns(%{label: "Column 3"}, true) | ||||||
|  |       ...> |> maybe_compose_columns(%{label: "Column 2"}, false) | ||||||
|  |       ...> |> maybe_compose_columns(%{label: "Column 1"}) | ||||||
|  |       [%{label: "Column 1"}, %{label: "Column 3"}] | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   @spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list() | ||||||
|  |   @spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list() | ||||||
|  |   def maybe_compose_columns(columns, element_or_elements, add? \\ true) | ||||||
|  |  | ||||||
|  |   def maybe_compose_columns(columns, elements, true) when is_list(elements), | ||||||
|  |     do: Enum.concat(elements, columns) | ||||||
|  |  | ||||||
|  |   def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns] | ||||||
|  |   def maybe_compose_columns(columns, _element_or_elements, false), do: columns | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,31 +1,32 @@ | |||||||
| <div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-black"> | <div id={@id} class="w-full overflow-x-auto border border-gray-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> | ||||||
|         <%= for %{key: key, label: label} = column <- @columns do %> |         <%= for %{key: key, label: label} = column <- @columns do %> | ||||||
|           <%= if column |> Map.get(:sortable, true) do %> |           <%= if column |> Map.get(:sortable, true) do %> | ||||||
|             <th class={"p-2 #{column[:class]}"}> |             <th class={["p-2", column[:class]]}> | ||||||
|               <span |               <span | ||||||
|                 class="cursor-pointer" |                 class="cursor-pointer flex justify-center items-center space-x-2" | ||||||
|                 phx-click="sort_by" |                 phx-click="sort_by" | ||||||
|                 phx-value-sort-key={key} |                 phx-value-sort-key={key} | ||||||
|                 phx-target={@myself} |                 phx-target={@myself} | ||||||
|               > |               > | ||||||
|                 <span class="underline"><%= label %></span> |                 <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> | ||||||
|                 <%= if @last_sort_key == key do %> |                 <%= if @last_sort_key == key do %> | ||||||
|                   <%= case @sort_mode do %> |                   <%= case @sort_mode do %> | ||||||
|                     <% :asc -> %> |                     <% :asc -> %> | ||||||
|                       <i class="fas fa-sm fa-chevron-down"></i> |                       <i class="w-0 float-right fas fa-sm fa-chevron-down"></i> | ||||||
|                     <% :desc -> %> |                     <% :desc -> %> | ||||||
|                       <i class="fas fa-sm fa-chevron-up"></i> |                       <i class="w-0 float-right fas fa-sm fa-chevron-up"></i> | ||||||
|                   <% end %> |                   <% end %> | ||||||
|                 <% else %> |                 <% else %> | ||||||
|                   <i class="fas fa-sm fa-chevron-up opacity-0"></i> |                   <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||||
|                 <% end %> |                 <% end %> | ||||||
|               </span> |               </span> | ||||||
|             </th> |             </th> | ||||||
|           <% else %> |           <% else %> | ||||||
|             <th class={"p-2 #{column[:class]}"}> |             <th class={["p-2 cursor-not-allowed", column[:class]]}> | ||||||
|               <%= label %> |               <%= label %> | ||||||
|             </th> |             </th> | ||||||
|           <% end %> |           <% end %> | ||||||
| @@ -33,10 +34,11 @@ | |||||||
|       </tr> |       </tr> | ||||||
|     </thead> |     </thead> | ||||||
|     <tbody> |     <tbody> | ||||||
|       <%= for values <- @rows do %> |       <tr | ||||||
|         <tr> |         :for={{values, i} <- @rows |> Enum.with_index()} | ||||||
|           <%= for %{key: key} = value <- @columns do %> |         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} | ||||||
|             <td 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 %> | ||||||
| @@ -44,9 +46,7 @@ | |||||||
|               <%= value %> |               <%= value %> | ||||||
|           <% end %> |           <% end %> | ||||||
|         </td> |         </td> | ||||||
|           <% end %> |  | ||||||
|       </tr> |       </tr> | ||||||
|       <% end %> |  | ||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,38 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.TagCard do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Display card for a tag |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|   alias Cannery.Tags.Tag |  | ||||||
|  |  | ||||||
|   attr :tag, Tag, required: true |  | ||||||
|   slot(:inner_block, required: true) |  | ||||||
|  |  | ||||||
|   def tag_card(assigns) do |  | ||||||
|     ~H""" |  | ||||||
|     <div |  | ||||||
|       id={"tag-#{@tag.id}"} |  | ||||||
|       class="mx-4 my-2 px-8 py-4 space-x-4 flex justify-center items-center |  | ||||||
|           border border-gray-400 rounded-lg shadow-lg hover:shadow-md |  | ||||||
|           transition-all duration-300 ease-in-out" |  | ||||||
|     > |  | ||||||
|       <.simple_tag_card tag={@tag} /> |  | ||||||
|       <%= render_slot(@inner_block) %> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   attr :tag, Tag, required: true |  | ||||||
|  |  | ||||||
|   def simple_tag_card(assigns) do |  | ||||||
|     ~H""" |  | ||||||
|     <h1 |  | ||||||
|       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}"} |  | ||||||
|     > |  | ||||||
|       <%= @tag.name %> |  | ||||||
|     </h1> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -1,146 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.Topbar do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Component that renders a topbar with user functions/links |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|  |  | ||||||
|   alias Cannery.Accounts |  | ||||||
|   alias CanneryWeb.{Endpoint, HomeLive} |  | ||||||
|  |  | ||||||
|   def topbar(assigns) do |  | ||||||
|     assigns = |  | ||||||
|       %{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns) |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400"> |  | ||||||
|       <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"> |  | ||||||
|           <.link |  | ||||||
|             navigate={Routes.live_path(Endpoint, HomeLive)} |  | ||||||
|             class="inline mx-2 my-1 leading-5 text-xl text-white" |  | ||||||
|           > |  | ||||||
|             <img |  | ||||||
|               src={Routes.static_path(Endpoint, "/images/cannery.svg")} |  | ||||||
|               alt={gettext("Cannery logo")} |  | ||||||
|               class="inline-block h-8 mx-1" |  | ||||||
|             /> |  | ||||||
|             <h1 class="inline hover:underline">Cannery</h1> |  | ||||||
|           </.link> |  | ||||||
|  |  | ||||||
|           <%= if @title_content do %> |  | ||||||
|             <span class="mx-2 my-1"> |  | ||||||
|               | |  | ||||||
|             </span> |  | ||||||
|             <%= @title_content %> |  | ||||||
|           <% end %> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <hr class="mb-2 sm:hidden hr-light" /> |  | ||||||
|  |  | ||||||
|         <ul class="flex flex-row flex-wrap justify-center items-center |  | ||||||
|           text-lg text-white text-ellipsis"> |  | ||||||
|           <%= if @current_user do %> |  | ||||||
|             <li class="mx-2 my-1"> |  | ||||||
|               <.link |  | ||||||
|                 navigate={Routes.tag_index_path(Endpoint, :index)} |  | ||||||
|                 class="text-primary-600 text-white hover:underline" |  | ||||||
|               > |  | ||||||
|                 <%= gettext("Tags") %> |  | ||||||
|               </.link> |  | ||||||
|             </li> |  | ||||||
|             <li class="mx-2 my-1"> |  | ||||||
|               <.link |  | ||||||
|                 navigate={Routes.container_index_path(Endpoint, :index)} |  | ||||||
|                 class="text-primary-600 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-primary-600 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-primary-600 text-white hover:underline" |  | ||||||
|               > |  | ||||||
|                 <%= gettext("Ammo") %> |  | ||||||
|               </.link> |  | ||||||
|             </li> |  | ||||||
|             <li class="mx-2 my-1"> |  | ||||||
|               <.link |  | ||||||
|                 navigate={Routes.range_index_path(Endpoint, :index)} |  | ||||||
|                 class="text-primary-600 text-white hover:underline" |  | ||||||
|               > |  | ||||||
|                 <%= gettext("Range") %> |  | ||||||
|               </.link> |  | ||||||
|             </li> |  | ||||||
|             <%= if @current_user.role == :admin do %> |  | ||||||
|               <li class="mx-2 my-1"> |  | ||||||
|                 <.link |  | ||||||
|                   navigate={Routes.invite_index_path(Endpoint, :index)} |  | ||||||
|                   class="text-primary-600 text-white hover:underline" |  | ||||||
|                 > |  | ||||||
|                   <%= gettext("Invites") %> |  | ||||||
|                 </.link> |  | ||||||
|               </li> |  | ||||||
|             <% end %> |  | ||||||
|             <li class="mx-2 my-1"> |  | ||||||
|               <.link |  | ||||||
|                 navigate={Routes.user_settings_path(Endpoint, :edit)} |  | ||||||
|                 class="text-primary-600 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" |  | ||||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to log out?")} |  | ||||||
|               > |  | ||||||
|                 <i class="fas fa-sign-out-alt"></i> |  | ||||||
|               </.link> |  | ||||||
|             </li> |  | ||||||
|             <%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %> |  | ||||||
|               <li class="mx-2 my-1"> |  | ||||||
|                 <.link |  | ||||||
|                   navigate={Routes.live_dashboard_path(Endpoint, :home)} |  | ||||||
|                   class="text-primary-600 text-white hover:underline" |  | ||||||
|                 > |  | ||||||
|                   <i class="fas fa-gauge"></i> |  | ||||||
|                 </.link> |  | ||||||
|               </li> |  | ||||||
|             <% end %> |  | ||||||
|           <% else %> |  | ||||||
|             <%= if Accounts.allow_registration?() do %> |  | ||||||
|               <li class="mx-2 my-1"> |  | ||||||
|                 <.link |  | ||||||
|                   navigate={Routes.user_registration_path(Endpoint, :new)} |  | ||||||
|                   class="text-primary-600 text-white hover:underline truncate" |  | ||||||
|                 > |  | ||||||
|                   <%= dgettext("actions", "Register") %> |  | ||||||
|                 </.link> |  | ||||||
|               </li> |  | ||||||
|             <% end %> |  | ||||||
|             <li class="mx-2 my-1"> |  | ||||||
|               <.link |  | ||||||
|                 navigate={Routes.user_session_path(Endpoint, :new)} |  | ||||||
|                 class="text-primary-600 text-white hover:underline truncate" |  | ||||||
|               > |  | ||||||
|                 <%= dgettext("actions", "Log in") %> |  | ||||||
|               </.link> |  | ||||||
|             </li> |  | ||||||
|           <% end %> |  | ||||||
|         </ul> |  | ||||||
|       </div> |  | ||||||
|     </nav> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										290
									
								
								lib/cannery_web/components/type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								lib/cannery_web/components/type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | |||||||
|  | defmodule CanneryWeb.Components.TypeTableComponent do | ||||||
|  |   @moduledoc """ | ||||||
|  |   A component that displays a list of types | ||||||
|  |   """ | ||||||
|  |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.Type} | ||||||
|  |   alias CanneryWeb.Components.TableComponent | ||||||
|  |   alias Ecto.UUID | ||||||
|  |   alias Phoenix.LiveView.{Rendered, Socket} | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   @spec update( | ||||||
|  |           %{ | ||||||
|  |             required(:id) => UUID.t(), | ||||||
|  |             required(:current_user) => User.t(), | ||||||
|  |             optional(:class) => Type.class() | nil, | ||||||
|  |             optional(:show_used) => boolean(), | ||||||
|  |             optional(:types) => [Type.t()], | ||||||
|  |             optional(:actions) => Rendered.t(), | ||||||
|  |             optional(any()) => any() | ||||||
|  |           }, | ||||||
|  |           Socket.t() | ||||||
|  |         ) :: {:ok, Socket.t()} | ||||||
|  |   def update(%{id: _id, types: _types, current_user: _current_user} = assigns, socket) do | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(assigns) | ||||||
|  |       |> assign_new(:show_used, fn -> false end) | ||||||
|  |       |> assign_new(:class, fn -> :all end) | ||||||
|  |       |> assign_new(:actions, fn -> [] end) | ||||||
|  |       |> display_types() | ||||||
|  |  | ||||||
|  |     {:ok, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp display_types( | ||||||
|  |          %{ | ||||||
|  |            assigns: %{ | ||||||
|  |              types: types, | ||||||
|  |              current_user: current_user, | ||||||
|  |              show_used: show_used, | ||||||
|  |              class: class, | ||||||
|  |              actions: actions | ||||||
|  |            } | ||||||
|  |          } = socket | ||||||
|  |        ) do | ||||||
|  |     filtered_columns = | ||||||
|  |       [ | ||||||
|  |         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, | ||||||
|  |         %{ | ||||||
|  |           label: if(class == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")), | ||||||
|  |           key: :caliber, | ||||||
|  |           type: :string | ||||||
|  |         }, | ||||||
|  |         %{label: gettext("Unfired shell length"), key: :unfired_length, 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("Grains"), key: :grains, type: :string}, | ||||||
|  |         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||||
|  |         %{ | ||||||
|  |           label: if(class == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")), | ||||||
|  |           key: :bullet_core, | ||||||
|  |           type: :string | ||||||
|  |         }, | ||||||
|  |         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, | ||||||
|  |         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||||
|  |         %{label: gettext("Wadding"), key: :wadding, type: :string}, | ||||||
|  |         %{label: gettext("Shot type"), key: :shot_type, type: :string}, | ||||||
|  |         %{label: gettext("Shot material"), key: :shot_material, type: :string}, | ||||||
|  |         %{label: gettext("Shot size"), key: :shot_size, type: :string}, | ||||||
|  |         %{label: gettext("Load grains"), key: :load_grains, type: :string}, | ||||||
|  |         %{label: gettext("Shot charge weight"), key: :shot_charge_weight, type: :string}, | ||||||
|  |         %{label: gettext("Powder type"), key: :powder_type, type: :string}, | ||||||
|  |         %{ | ||||||
|  |           label: gettext("Powder grains per charge"), | ||||||
|  |           key: :powder_grains_per_charge, | ||||||
|  |           type: :string | ||||||
|  |         }, | ||||||
|  |         %{label: gettext("Pressure"), key: :pressure, type: :string}, | ||||||
|  |         %{label: gettext("Dram equivalent"), key: :dram_equivalent, type: :string}, | ||||||
|  |         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||||
|  |         %{label: gettext("Primer type"), key: :primer_type, type: :string}, | ||||||
|  |         %{label: gettext("Firing type"), key: :firing_type, type: :string}, | ||||||
|  |         %{label: gettext("Tracer"), key: :tracer, type: :atom}, | ||||||
|  |         %{label: gettext("Incendiary"), key: :incendiary, type: :atom}, | ||||||
|  |         %{label: gettext("Blank"), key: :blank, type: :atom}, | ||||||
|  |         %{label: gettext("Corrosive"), key: :corrosive, type: :atom}, | ||||||
|  |         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string} | ||||||
|  |       ] | ||||||
|  |       |> Enum.filter(fn %{key: key, type: type} -> | ||||||
|  |         # remove columns if all values match defaults | ||||||
|  |         default_value = if type == :atom, do: false, else: nil | ||||||
|  |  | ||||||
|  |         types | ||||||
|  |         |> Enum.any?(fn type -> Map.get(type, key, default_value) != default_value end) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     columns = | ||||||
|  |       [%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}] | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: gettext("Average CPR"), | ||||||
|  |         key: :avg_price_paid, | ||||||
|  |         type: :avg_price_paid | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{ | ||||||
|  |           label: gettext("Total ever packs"), | ||||||
|  |           key: :historical_pack_count, | ||||||
|  |           type: :historical_pack_count | ||||||
|  |         }, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{ | ||||||
|  |           label: gettext("Used packs"), | ||||||
|  |           key: :used_pack_count, | ||||||
|  |           type: :used_pack_count | ||||||
|  |         }, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: gettext("Packs"), | ||||||
|  |         key: :ammo_count, | ||||||
|  |         type: :ammo_count | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{ | ||||||
|  |           label: gettext("Total ever rounds"), | ||||||
|  |           key: :historical_round_count, | ||||||
|  |           type: :historical_round_count | ||||||
|  |         }, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{ | ||||||
|  |           label: gettext("Used rounds"), | ||||||
|  |           key: :used_round_count, | ||||||
|  |           type: :used_round_count | ||||||
|  |         }, | ||||||
|  |         show_used | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{ | ||||||
|  |         label: gettext("Rounds"), | ||||||
|  |         key: :round_count, | ||||||
|  |         type: :round_count | ||||||
|  |       }) | ||||||
|  |       |> TableComponent.maybe_compose_columns(filtered_columns) | ||||||
|  |       |> TableComponent.maybe_compose_columns( | ||||||
|  |         %{label: gettext("Class"), key: :class, type: :atom}, | ||||||
|  |         class in [:all, nil] | ||||||
|  |       ) | ||||||
|  |       |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) | ||||||
|  |  | ||||||
|  |     round_counts = types |> Ammo.get_round_count_for_types(current_user) | ||||||
|  |     packs_count = types |> Ammo.get_packs_count_for_types(current_user) | ||||||
|  |     average_costs = types |> Ammo.get_average_cost_for_types(current_user) | ||||||
|  |  | ||||||
|  |     [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = | ||||||
|  |       if show_used do | ||||||
|  |         [ | ||||||
|  |           types |> ActivityLog.get_used_count_for_types(current_user), | ||||||
|  |           types |> Ammo.get_historical_count_for_types(current_user), | ||||||
|  |           types |> Ammo.get_packs_count_for_types(current_user, true), | ||||||
|  |           types |> Ammo.get_used_packs_count_for_types(current_user) | ||||||
|  |         ] | ||||||
|  |       else | ||||||
|  |         [nil, nil, nil, nil] | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     extra_data = %{ | ||||||
|  |       actions: actions, | ||||||
|  |       current_user: current_user, | ||||||
|  |       used_counts: used_counts, | ||||||
|  |       round_counts: round_counts, | ||||||
|  |       historical_round_counts: historical_round_counts, | ||||||
|  |       packs_count: packs_count, | ||||||
|  |       used_pack_counts: used_pack_counts, | ||||||
|  |       historical_pack_counts: historical_pack_counts, | ||||||
|  |       average_costs: average_costs | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     rows = | ||||||
|  |       types | ||||||
|  |       |> Enum.map(fn type -> | ||||||
|  |         type |> get_type_values(columns, extra_data) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     socket |> assign(columns: columns, rows: rows) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @impl true | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div id={@id} class="w-full"> | ||||||
|  |       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_values(type, columns, extra_data) do | ||||||
|  |     columns | ||||||
|  |     |> Map.new(fn %{key: key, type: column_type} -> | ||||||
|  |       {key, get_type_value(column_type, key, type, extra_data)} | ||||||
|  |     end) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value(:atom, key, type, _other_data), | ||||||
|  |     do: type |> Map.get(key) |> humanize() | ||||||
|  |  | ||||||
|  |   defp get_type_value(:round_count, _key, %{id: type_id}, %{round_counts: round_counts}), | ||||||
|  |     do: Map.get(round_counts, type_id, 0) | ||||||
|  |  | ||||||
|  |   defp get_type_value( | ||||||
|  |          :historical_round_count, | ||||||
|  |          _key, | ||||||
|  |          %{id: type_id}, | ||||||
|  |          %{historical_round_counts: historical_round_counts} | ||||||
|  |        ) do | ||||||
|  |     Map.get(historical_round_counts, type_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value( | ||||||
|  |          :used_round_count, | ||||||
|  |          _key, | ||||||
|  |          %{id: type_id}, | ||||||
|  |          %{used_counts: used_counts} | ||||||
|  |        ) do | ||||||
|  |     Map.get(used_counts, type_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value( | ||||||
|  |          :historical_pack_count, | ||||||
|  |          _key, | ||||||
|  |          %{id: type_id}, | ||||||
|  |          %{historical_pack_counts: historical_pack_counts} | ||||||
|  |        ) do | ||||||
|  |     Map.get(historical_pack_counts, type_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value( | ||||||
|  |          :used_pack_count, | ||||||
|  |          _key, | ||||||
|  |          %{id: type_id}, | ||||||
|  |          %{used_pack_counts: used_pack_counts} | ||||||
|  |        ) do | ||||||
|  |     Map.get(used_pack_counts, type_id, 0) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value(:ammo_count, _key, %{id: type_id}, %{packs_count: packs_count}), | ||||||
|  |     do: Map.get(packs_count, type_id) | ||||||
|  |  | ||||||
|  |   defp get_type_value( | ||||||
|  |          :avg_price_paid, | ||||||
|  |          _key, | ||||||
|  |          %{id: type_id}, | ||||||
|  |          %{average_costs: average_costs} | ||||||
|  |        ) do | ||||||
|  |     case Map.get(average_costs, type_id) do | ||||||
|  |       nil -> {0, gettext("No cost information")} | ||||||
|  |       count -> {count, gettext("$%{amount}", amount: display_currency(count))} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value(:name, _key, %{name: type_name} = type, _other_data) do | ||||||
|  |     assigns = %{type: type} | ||||||
|  |  | ||||||
|  |     {type_name, | ||||||
|  |      ~H""" | ||||||
|  |      <.link navigate={Routes.type_show_path(Endpoint, :show, @type)} class="link"> | ||||||
|  |        <%= @type.name %> | ||||||
|  |      </.link> | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value(:actions, _key, type, %{actions: actions}) do | ||||||
|  |     assigns = %{actions: actions, type: type} | ||||||
|  |  | ||||||
|  |     ~H""" | ||||||
|  |     <%= render_slot(@actions, @type) %> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_type_value(nil, _key, _type, _other_data), do: nil | ||||||
|  |  | ||||||
|  |   defp get_type_value(_other, key, type, _other_data), do: type |> Map.get(key) | ||||||
|  |  | ||||||
|  |   @spec display_currency(float()) :: String.t() | ||||||
|  |   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||||
|  | end | ||||||
| @@ -1,47 +0,0 @@ | |||||||
| defmodule CanneryWeb.Components.UserCard do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Display card for a user |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :component |  | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|  |  | ||||||
|   attr :user, User, required: true |  | ||||||
|   slot(:inner_block, required: true) |  | ||||||
|  |  | ||||||
|   def user_card(assigns) do |  | ||||||
|     ~H""" |  | ||||||
|     <div |  | ||||||
|       id={"user-#{@user.id}"} |  | ||||||
|       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 |  | ||||||
|           transition-all duration-300 ease-in-out" |  | ||||||
|     > |  | ||||||
|       <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> |  | ||||||
|         <%= @user.email %> |  | ||||||
|       </h1> |  | ||||||
|  |  | ||||||
|       <h3 class="px-4 py-2 rounded-lg title text-lg"> |  | ||||||
|         <p> |  | ||||||
|           <%= if @user.confirmed_at |> is_nil() do %> |  | ||||||
|             Email unconfirmed |  | ||||||
|           <% else %> |  | ||||||
|             User was confirmed at <%= @user.confirmed_at |> display_datetime() %> |  | ||||||
|           <% end %> |  | ||||||
|         </p> |  | ||||||
|  |  | ||||||
|         <p> |  | ||||||
|           <%= gettext("User registered on") %> |  | ||||||
|           <%= @user.inserted_at |> display_datetime() %> |  | ||||||
|         </p> |  | ||||||
|       </h3> |  | ||||||
|  |  | ||||||
|       <%= if @inner_block do %> |  | ||||||
|         <div class="px-4 py-2 flex space-x-4 justify-center items-center"> |  | ||||||
|           <%= render_slot(@inner_block) %> |  | ||||||
|         </div> |  | ||||||
|       <% end %> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -3,62 +3,72 @@ 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 = |     types = Ammo.list_types(current_user, :all) | ||||||
|       Ammo.list_ammo_types(current_user) |     used_counts = types |> ActivityLog.get_used_count_for_types(current_user) | ||||||
|       |> Enum.map(fn ammo_type -> |     round_counts = types |> Ammo.get_round_count_for_types(current_user) | ||||||
|         average_cost = ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) |     pack_counts = types |> Ammo.get_packs_count_for_types(current_user) | ||||||
|         round_count = ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) |  | ||||||
|         used_count = ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) |  | ||||||
|         ammo_group_count = ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) |  | ||||||
|  |  | ||||||
|         ammo_type |     total_pack_counts = types |> Ammo.get_packs_count_for_types(current_user, true) | ||||||
|  |  | ||||||
|  |     average_costs = types |> Ammo.get_average_cost_for_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" => average_cost, |           "average_cost" => Map.get(average_costs, type_id), | ||||||
|           "round_count" => round_count, |           "round_count" => Map.get(round_counts, type_id, 0), | ||||||
|           "used_count" => used_count, |           "used_count" => Map.get(used_counts, type_id, 0), | ||||||
|           "ammo_group_count" => ammo_group_count |           "pack_count" => Map.get(pack_counts, type_id, 0), | ||||||
|  |           "total_pack_count" => Map.get(total_pack_counts, type_id, 0) | ||||||
|         }) |         }) | ||||||
|       end) |       end) | ||||||
|  |  | ||||||
|     ammo_groups = |     packs = Ammo.list_packs(nil, :all, current_user, true) | ||||||
|       Ammo.list_ammo_groups(current_user, true) |     used_counts = packs |> ActivityLog.get_used_counts(current_user) | ||||||
|       |> Enum.map(fn ammo_group -> |     original_counts = packs |> Ammo.get_original_counts(current_user) | ||||||
|         used_count = ammo_group |> Ammo.get_used_count() |     cprs = packs |> Ammo.get_cprs(current_user) | ||||||
|         percentage_remaining = ammo_group |> Ammo.get_percentage_remaining() |     percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user) | ||||||
|  |  | ||||||
|         ammo_group |     packs = | ||||||
|  |       packs | ||||||
|  |       |> Enum.map(fn %{id: pack_id} = pack -> | ||||||
|  |         pack | ||||||
|         |> Jason.encode!() |         |> Jason.encode!() | ||||||
|         |> Jason.decode!() |         |> Jason.decode!() | ||||||
|         |> Map.merge(%{ |         |> Map.merge(%{ | ||||||
|           "used_count" => used_count, |           "used_count" => Map.get(used_counts, pack_id), | ||||||
|           "percentage_remaining" => percentage_remaining |           "percentage_remaining" => Map.fetch!(percentages_remaining, pack_id), | ||||||
|  |           "original_count" => Map.get(original_counts, pack_id), | ||||||
|  |           "cpr" => Map.get(cprs, pack_id) | ||||||
|         }) |         }) | ||||||
|       end) |       end) | ||||||
|  |  | ||||||
|     shot_groups = ActivityLog.list_shot_groups(current_user) |     shot_records = ActivityLog.list_shot_records(:all, 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 |> Containers.get_container_ammo_group_count!() |         pack_count = container |> Ammo.get_packs_count_for_container!(current_user) | ||||||
|         round_count = container |> Containers.get_container_rounds!() |         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" => pack_count, | ||||||
|           "round_count" => round_count |           "round_count" => round_count | ||||||
|         }) |         }) | ||||||
|       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 | ||||||
|   | |||||||
| @@ -1,4 +1,8 @@ | |||||||
| defmodule CanneryWeb.HomeController do | defmodule CanneryWeb.HomeController do | ||||||
|  |   @moduledoc """ | ||||||
|  |   Controller for home page | ||||||
|  |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :controller |   use CanneryWeb, :controller | ||||||
|  |  | ||||||
|   def index(conn, _params) do |   def index(conn, _params) do | ||||||
|   | |||||||
| @@ -1,15 +1,13 @@ | |||||||
| defmodule CanneryWeb.UserRegistrationController do | defmodule CanneryWeb.UserRegistrationController do | ||||||
|   use CanneryWeb, :controller |   use CanneryWeb, :controller | ||||||
|   import CanneryWeb.Gettext |   import CanneryWeb.Gettext | ||||||
|   alias Cannery.{Accounts, Invites} |   alias Cannery.{Accounts, Accounts.Invites} | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|   alias CanneryWeb.{Endpoint, HomeLive} |   alias CanneryWeb.{Endpoint, HomeLive} | ||||||
|  |   alias Ecto.Changeset | ||||||
|  |  | ||||||
|   def new(conn, %{"invite" => invite_token}) do |   def new(conn, %{"invite" => invite_token}) do | ||||||
|     invite = Invites.get_invite_by_token(invite_token) |     if Invites.valid_invite_token?(invite_token) do | ||||||
|  |       conn |> render_new(invite_token) | ||||||
|     if invite do |  | ||||||
|       conn |> render_new(invite) |  | ||||||
|     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")) | ||||||
| @@ -28,19 +26,17 @@ defmodule CanneryWeb.UserRegistrationController do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   # renders new user registration page |   # renders new user registration page | ||||||
|   defp render_new(conn, invite \\ nil) do |   defp render_new(conn, invite_token \\ nil) do | ||||||
|     render(conn, "new.html", |     render(conn, "new.html", | ||||||
|       changeset: Accounts.change_user_registration(%User{}), |       changeset: Accounts.change_user_registration(), | ||||||
|       invite: invite, |       invite_token: invite_token, | ||||||
|       page_title: gettext("Register") |       page_title: gettext("Register") | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do |   def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do | ||||||
|     invite = Invites.get_invite_by_token(invite_token) |     if Invites.valid_invite_token?(invite_token) do | ||||||
|  |       conn |> create_user(attrs, invite_token) | ||||||
|     if invite do |  | ||||||
|       conn |> create_user(attrs, invite) |  | ||||||
|     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")) | ||||||
| @@ -58,13 +54,9 @@ defmodule CanneryWeb.UserRegistrationController do | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp create_user(conn, %{"user" => user_params}, invite \\ nil) do |   defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do | ||||||
|     case Accounts.register_user(user_params) do |     case Accounts.register_user(user_params, invite_token) do | ||||||
|       {:ok, user} -> |       {:ok, user} -> | ||||||
|         unless invite |> is_nil() do |  | ||||||
|           invite |> Invites.use_invite!() |  | ||||||
|         end |  | ||||||
|  |  | ||||||
|         Accounts.deliver_user_confirmation_instructions( |         Accounts.deliver_user_confirmation_instructions( | ||||||
|           user, |           user, | ||||||
|           &Routes.user_confirmation_url(conn, :confirm, &1) |           &Routes.user_confirmation_url(conn, :confirm, &1) | ||||||
| @@ -74,8 +66,13 @@ defmodule CanneryWeb.UserRegistrationController do | |||||||
|         |> 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: Routes.user_session_path(Endpoint, :new)) | ||||||
|  |  | ||||||
|       {:error, %Ecto.Changeset{} = changeset} -> |       {:error, :invalid_token} -> | ||||||
|         conn |> render("new.html", changeset: changeset, invite: invite) |         conn | ||||||
|  |         |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||||
|  |         |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||||
|  |  | ||||||
|  |       {:error, %Changeset{} = changeset} -> | ||||||
|  |         conn |> render("new.html", changeset: changeset, invite_token: invite_token) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,191 +0,0 @@ | |||||||
| defmodule CanneryWeb.AmmoGroupLive.FormComponent do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Livecomponent that can update or create an Cannery.Ammo.AmmoGroup |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |  | ||||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} |  | ||||||
|   alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container} |  | ||||||
|   alias Ecto.Changeset |  | ||||||
|   alias Phoenix.LiveView.Socket |  | ||||||
|  |  | ||||||
|   @ammo_group_create_limit 10_000 |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   @spec update( |  | ||||||
|           %{:ammo_group => AmmoGroup.t(), :current_user => User.t(), optional(any) => any}, |  | ||||||
|           Socket.t() |  | ||||||
|         ) :: {:ok, Socket.t()} |  | ||||||
|   def update(%{ammo_group: _ammo_group} = assigns, socket) do |  | ||||||
|     socket |> assign(assigns) |> update() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @spec update(Socket.t()) :: {:ok, Socket.t()} |  | ||||||
|   def update(%{assigns: %{current_user: current_user}} = socket) do |  | ||||||
|     %{assigns: %{ammo_types: ammo_types, containers: containers}} = |  | ||||||
|       socket = |  | ||||||
|       socket |  | ||||||
|       |> assign(:ammo_group_create_limit, @ammo_group_create_limit) |  | ||||||
|       |> assign(:ammo_types, Ammo.list_ammo_types(current_user)) |  | ||||||
|       |> assign_new(:containers, fn -> Containers.list_containers(current_user) end) |  | ||||||
|  |  | ||||||
|     params = |  | ||||||
|       if ammo_types |> List.first() |> is_nil(), |  | ||||||
|         do: %{}, |  | ||||||
|         else: %{} |> Map.put("ammo_type_id", ammo_types |> List.first() |> Map.get(:id)) |  | ||||||
|  |  | ||||||
|     params = |  | ||||||
|       if containers |> List.first() |> is_nil(), |  | ||||||
|         do: params, |  | ||||||
|         else: params |> Map.put("container_id", containers |> List.first() |> Map.get(:id)) |  | ||||||
|  |  | ||||||
|     {:ok, socket |> assign_changeset(params)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do |  | ||||||
|     {:noreply, socket |> assign_changeset(ammo_group_params)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def handle_event( |  | ||||||
|         "save", |  | ||||||
|         %{"ammo_group" => ammo_group_params}, |  | ||||||
|         %{assigns: %{action: action}} = socket |  | ||||||
|       ) do |  | ||||||
|     save_ammo_group(socket, action, ammo_group_params) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   # HTML Helpers |  | ||||||
|   @spec container_options([Container.t()]) :: [{String.t(), Container.id()}] |  | ||||||
|   defp container_options(containers) do |  | ||||||
|     containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @spec ammo_type_options([AmmoType.t()]) :: [{String.t(), AmmoType.id()}] |  | ||||||
|   defp ammo_type_options(ammo_types) do |  | ||||||
|     ammo_types |> Enum.map(fn %{id: id, name: name} -> {name, id} end) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   # Save Helpers |  | ||||||
|  |  | ||||||
|   defp assign_changeset( |  | ||||||
|          %{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket, |  | ||||||
|          ammo_group_params |  | ||||||
|        ) do |  | ||||||
|     changeset_action = |  | ||||||
|       cond do |  | ||||||
|         action in [:new, :clone] -> :insert |  | ||||||
|         action == :edit -> :update |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     changeset = |  | ||||||
|       cond do |  | ||||||
|         action in [:new, :clone] -> |  | ||||||
|           ammo_type = |  | ||||||
|             if ammo_group_params |> Map.has_key?("ammo_type_id"), |  | ||||||
|               do: ammo_group_params |> Map.get("ammo_type_id") |> Ammo.get_ammo_type!(user), |  | ||||||
|               else: nil |  | ||||||
|  |  | ||||||
|           container = |  | ||||||
|             if ammo_group_params |> Map.has_key?("container_id"), |  | ||||||
|               do: ammo_group_params |> Map.get("container_id") |> Containers.get_container!(user), |  | ||||||
|               else: nil |  | ||||||
|  |  | ||||||
|           ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params) |  | ||||||
|  |  | ||||||
|         action == :edit -> |  | ||||||
|           ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user) |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     changeset = |  | ||||||
|       case changeset |> Changeset.apply_action(changeset_action) do |  | ||||||
|         {:ok, _data} -> changeset |  | ||||||
|         {:error, changeset} -> changeset |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     socket |> assign(:changeset, changeset) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp save_ammo_group( |  | ||||||
|          %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = |  | ||||||
|            socket, |  | ||||||
|          :edit, |  | ||||||
|          ammo_group_params |  | ||||||
|        ) do |  | ||||||
|     socket = |  | ||||||
|       case Ammo.update_ammo_group(ammo_group, ammo_group_params, current_user) do |  | ||||||
|         {:ok, _ammo_group} -> |  | ||||||
|           prompt = dgettext("prompts", "Ammo updated successfully") |  | ||||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) |  | ||||||
|  |  | ||||||
|         {:error, %Changeset{} = changeset} -> |  | ||||||
|           socket |> assign(:changeset, changeset) |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     {:noreply, socket} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp save_ammo_group( |  | ||||||
|          %{assigns: %{changeset: changeset}} = socket, |  | ||||||
|          action, |  | ||||||
|          %{"multiplier" => multiplier_str} = ammo_group_params |  | ||||||
|        ) |  | ||||||
|        when action in [:new, :clone] do |  | ||||||
|     socket = |  | ||||||
|       case multiplier_str |> Integer.parse() do |  | ||||||
|         {multiplier, _remainder} |  | ||||||
|         when multiplier >= 1 and multiplier <= @ammo_group_create_limit -> |  | ||||||
|           socket |> create_multiple(ammo_group_params, multiplier) |  | ||||||
|  |  | ||||||
|         {multiplier, _remainder} -> |  | ||||||
|           error_msg = |  | ||||||
|             dgettext( |  | ||||||
|               "errors", |  | ||||||
|               "Invalid number of copies, must be between 1 and %{max}. Was %{multiplier}", |  | ||||||
|               max: @ammo_group_create_limit, |  | ||||||
|               multiplier: multiplier |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|           {:error, changeset} = |  | ||||||
|             changeset |  | ||||||
|             |> Changeset.add_error(:multiplier, error_msg) |  | ||||||
|             |> Changeset.apply_action(:insert) |  | ||||||
|  |  | ||||||
|           socket |> assign(:changeset, changeset) |  | ||||||
|  |  | ||||||
|         :error -> |  | ||||||
|           error_msg = dgettext("errors", "Could not parse number of copies") |  | ||||||
|  |  | ||||||
|           {:error, changeset} = |  | ||||||
|             changeset |  | ||||||
|             |> Changeset.add_error(:multiplier, error_msg) |  | ||||||
|             |> Changeset.apply_action(:insert) |  | ||||||
|  |  | ||||||
|           socket |> assign(:changeset, changeset) |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     {:noreply, socket} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp create_multiple( |  | ||||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, |  | ||||||
|          ammo_group_params, |  | ||||||
|          multiplier |  | ||||||
|        ) do |  | ||||||
|     case Ammo.create_ammo_groups(ammo_group_params, multiplier, current_user) do |  | ||||||
|       {:ok, {count, _ammo_groups}} -> |  | ||||||
|         prompt = |  | ||||||
|           dngettext( |  | ||||||
|             "prompts", |  | ||||||
|             "Ammo added successfully", |  | ||||||
|             "Ammo added successfully", |  | ||||||
|             count |  | ||||||
|           ) |  | ||||||
|  |  | ||||||
|         socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) |  | ||||||
|  |  | ||||||
|       {:error, %Changeset{} = changeset} -> |  | ||||||
|         socket |> assign(changeset: changeset) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -1,277 +0,0 @@ | |||||||
| defmodule CanneryWeb.AmmoGroupLive.Index do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Liveview to show a Cannery.Ammo.AmmoGroup index |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |  | ||||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Containers, Repo} |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def mount(_params, _session, socket) do |  | ||||||
|     {:ok, socket |> assign(show_used: false)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do |  | ||||||
|     {:noreply, apply_action(socket, live_action, params) |> display_ammo_groups()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action( |  | ||||||
|          %{assigns: %{current_user: current_user}} = socket, |  | ||||||
|          :add_shot_group, |  | ||||||
|          %{"id" => id} |  | ||||||
|        ) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("Record shots")) |  | ||||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :move, %{"id" => id}) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("Move Ammo group")) |  | ||||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("Edit Ammo group")) |  | ||||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, dgettext("actions", "Add Ammo")) |  | ||||||
|     |> assign(:ammo_group, %{Ammo.get_ammo_group!(id, current_user) | id: nil}) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(socket, :new, _params) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, dgettext("actions", "Add Ammo")) |  | ||||||
|     |> assign(:ammo_group, %AmmoGroup{}) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(socket, :index, _params) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("Ammo groups")) |  | ||||||
|     |> assign(:ammo_group, nil) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do |  | ||||||
|     Ammo.get_ammo_group!(id, current_user) |> Ammo.delete_ammo_group!(current_user) |  | ||||||
|  |  | ||||||
|     prompt = dgettext("prompts", "Ammo group deleted succesfully") |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "toggle_staged", |  | ||||||
|         %{"ammo_group_id" => id}, |  | ||||||
|         %{assigns: %{current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     ammo_group = Ammo.get_ammo_group!(id, current_user) |  | ||||||
|  |  | ||||||
|     {:ok, _ammo_group} = |  | ||||||
|       ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> display_ammo_groups()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do |  | ||||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp display_ammo_groups( |  | ||||||
|          %{assigns: %{current_user: current_user, show_used: show_used}} = socket |  | ||||||
|        ) do |  | ||||||
|     ammo_groups = |  | ||||||
|       Ammo.list_ammo_groups(current_user, show_used) |  | ||||||
|       |> Repo.preload([:ammo_type, :container], force: true) |  | ||||||
|  |  | ||||||
|     ammo_types_count = Ammo.get_ammo_types_count!(current_user) |  | ||||||
|     containers_count = Containers.get_containers_count!(current_user) |  | ||||||
|  |  | ||||||
|     columns = [ |  | ||||||
|       %{label: gettext("Ammo type"), key: :ammo_type}, |  | ||||||
|       %{label: gettext("Count"), key: :count}, |  | ||||||
|       %{label: gettext("Price paid"), key: :price_paid}, |  | ||||||
|       %{label: gettext("% left"), key: :remaining}, |  | ||||||
|       %{label: gettext("Range"), key: :range}, |  | ||||||
|       %{label: gettext("Container"), key: :container}, |  | ||||||
|       %{label: gettext("Added on"), key: :added_on} |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     columns = |  | ||||||
|       if show_used do |  | ||||||
|         columns ++ [%{label: gettext("Used up on"), key: :used_up_on}] |  | ||||||
|       else |  | ||||||
|         columns |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     columns = columns ++ [%{label: nil, key: :actions, sortable: false}] |  | ||||||
|  |  | ||||||
|     rows = |  | ||||||
|       ammo_groups |  | ||||||
|       |> Enum.map(fn ammo_group -> ammo_group |> get_row_data_for_ammo_group(columns) end) |  | ||||||
|  |  | ||||||
|     socket |  | ||||||
|     |> assign( |  | ||||||
|       ammo_groups: ammo_groups, |  | ||||||
|       ammo_types_count: ammo_types_count, |  | ||||||
|       containers_count: containers_count, |  | ||||||
|       columns: columns, |  | ||||||
|       rows: rows |  | ||||||
|     ) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @spec get_row_data_for_ammo_group(AmmoGroup.t(), [map()]) :: [map()] |  | ||||||
|   defp get_row_data_for_ammo_group(ammo_group, columns) do |  | ||||||
|     ammo_group = ammo_group |> Repo.preload([:ammo_type, :container]) |  | ||||||
|  |  | ||||||
|     columns |  | ||||||
|     |> Enum.into(%{}, fn %{key: key} -> {key, get_value_for_key(key, ammo_group)} end) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @spec get_value_for_key(atom(), AmmoGroup.t()) :: any() |  | ||||||
|   defp get_value_for_key(:ammo_type, %{ammo_type: ammo_type}) do |  | ||||||
|     assigns = %{ammo_type: ammo_type} |  | ||||||
|  |  | ||||||
|     {ammo_type.name, |  | ||||||
|      ~H""" |  | ||||||
|      <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link"> |  | ||||||
|        <%= @ammo_type.name %> |  | ||||||
|      </.link> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}), do: {"a", nil} |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:price_paid, %{price_paid: price_paid}), |  | ||||||
|     do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2)) |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:added_on, %{inserted_at: inserted_at}) do |  | ||||||
|     assigns = %{inserted_at: inserted_at} |  | ||||||
|  |  | ||||||
|     {inserted_at, |  | ||||||
|      ~H""" |  | ||||||
|      <%= @inserted_at |> display_datetime() %> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:used_up_on, ammo_group) do |  | ||||||
|     last_shot_group_date = |  | ||||||
|       case ammo_group |> Ammo.get_last_used_shot_group() do |  | ||||||
|         %{date: last_shot_group_date} -> last_shot_group_date |  | ||||||
|         _no_shot_groups -> nil |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     assigns = %{last_shot_group_date: last_shot_group_date} |  | ||||||
|  |  | ||||||
|     {last_shot_group_date, |  | ||||||
|      ~H""" |  | ||||||
|      <%= @last_shot_group_date |> display_date() %> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:range, %{staged: staged} = ammo_group) do |  | ||||||
|     assigns = %{ammo_group: ammo_group} |  | ||||||
|  |  | ||||||
|     {staged, |  | ||||||
|      ~H""" |  | ||||||
|      <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> |  | ||||||
|        <button |  | ||||||
|          type="button" |  | ||||||
|          class="mx-2 my-1 text-sm btn btn-primary" |  | ||||||
|          phx-click="toggle_staged" |  | ||||||
|          phx-value-ammo_group_id={@ammo_group.id} |  | ||||||
|        > |  | ||||||
|          <%= if @ammo_group.staged, do: gettext("Unstage"), else: gettext("Stage") %> |  | ||||||
|        </button> |  | ||||||
|  |  | ||||||
|        <.link |  | ||||||
|          patch={Routes.ammo_group_index_path(Endpoint, :add_shot_group, @ammo_group)} |  | ||||||
|          class="mx-2 my-1 text-sm btn btn-primary" |  | ||||||
|        > |  | ||||||
|          <%= dgettext("actions", "Record shots") %> |  | ||||||
|        </.link> |  | ||||||
|      </div> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:remaining, ammo_group), |  | ||||||
|     do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining()) |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:actions, ammo_group) do |  | ||||||
|     assigns = %{ammo_group: ammo_group} |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> |  | ||||||
|       <.link |  | ||||||
|         navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"view-#{@ammo_group.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-eye"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         patch={Routes.ammo_group_index_path(Endpoint, :edit, @ammo_group)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"edit-#{@ammo_group.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         patch={Routes.ammo_group_index_path(Endpoint, :clone, @ammo_group)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"clone-#{@ammo_group.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-copy"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         href="#" |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         phx-click="delete" |  | ||||||
|         phx-value-id={@ammo_group.id} |  | ||||||
|         data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} |  | ||||||
|         data-qa={"delete-#{@ammo_group.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> |  | ||||||
|       </.link> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:container, %{container: nil}), do: {nil, nil} |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:container, %{container: %{name: container_name}} = ammo_group) do |  | ||||||
|     assigns = %{ammo_group: ammo_group} |  | ||||||
|  |  | ||||||
|     {container_name, |  | ||||||
|      ~H""" |  | ||||||
|      <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> |  | ||||||
|        <.link |  | ||||||
|          navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)} |  | ||||||
|          class="mx-2 my-1 link" |  | ||||||
|        > |  | ||||||
|          <%= @ammo_group.container.name %> |  | ||||||
|        </.link> |  | ||||||
|  |  | ||||||
|        <.link |  | ||||||
|          patch={Routes.ammo_group_index_path(Endpoint, :move, @ammo_group)} |  | ||||||
|          class="mx-2 my-1 text-sm btn btn-primary" |  | ||||||
|        > |  | ||||||
|          <%= gettext("Move ammo") %> |  | ||||||
|        </.link> |  | ||||||
|      </div> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(key, ammo_group), do: ammo_group |> Map.get(key) |  | ||||||
| end |  | ||||||
| @@ -1,102 +0,0 @@ | |||||||
| <div class="flex flex-col space-y-8 justify-center items-center"> |  | ||||||
|   <h1 class="title text-2xl title-primary-500"> |  | ||||||
|     <%= gettext("Ammo") %> |  | ||||||
|   </h1> |  | ||||||
|  |  | ||||||
|   <%= if @ammo_groups |> Enum.empty?() do %> |  | ||||||
|     <h2 class="title text-xl text-primary-600"> |  | ||||||
|       <%= gettext("No Ammo") %> |  | ||||||
|       <%= display_emoji("😔") %> |  | ||||||
|     </h2> |  | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <%= cond do %> |  | ||||||
|     <% @containers_count == 0 -> %> |  | ||||||
|       <div class="flex justify-center items-center"> |  | ||||||
|         <h2 class="m-2 title text-md text-primary-600"> |  | ||||||
|           <%= dgettext("prompts", "You'll need to") %> |  | ||||||
|         </h2> |  | ||||||
|  |  | ||||||
|         <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|           <%= dgettext("actions", "add a container first") %> |  | ||||||
|         </.link> |  | ||||||
|       </div> |  | ||||||
|     <% @ammo_types_count == 0 -> %> |  | ||||||
|       <div class="flex justify-center items-center"> |  | ||||||
|         <h2 class="m-2 title text-md text-primary-600"> |  | ||||||
|           <%= dgettext("prompts", "You'll need to") %> |  | ||||||
|         </h2> |  | ||||||
|  |  | ||||||
|         <.link navigate={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|           <%= dgettext("actions", "add an ammo type first") %> |  | ||||||
|         </.link> |  | ||||||
|       </div> |  | ||||||
|     <% @ammo_groups |> Enum.empty?() -> %> |  | ||||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|         <%= dgettext("actions", "Add your first box!") %> |  | ||||||
|       </.link> |  | ||||||
|     <% true -> %> |  | ||||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|         <%= dgettext("actions", "Add Ammo") %> |  | ||||||
|       </.link> |  | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> |  | ||||||
|     <div class="flex flex-col justify-center items-center"> |  | ||||||
|       <.toggle_button action="toggle_show_used" value={@show_used}> |  | ||||||
|         <span class="title text-lg text-primary-600"> |  | ||||||
|           <%= gettext("Show used") %> |  | ||||||
|         </span> |  | ||||||
|       </.toggle_button> |  | ||||||
|     </div> |  | ||||||
|  |  | ||||||
|     <.live_component |  | ||||||
|       module={CanneryWeb.Components.TableComponent} |  | ||||||
|       id="ammo_groups_index_table" |  | ||||||
|       action={@live_action} |  | ||||||
|       columns={@columns} |  | ||||||
|       rows={@rows} |  | ||||||
|     /> |  | ||||||
|   <% end %> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <%= cond do %> |  | ||||||
|   <% @live_action in [:new, :edit, :clone] -> %> |  | ||||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> |  | ||||||
|       <.live_component |  | ||||||
|         module={CanneryWeb.AmmoGroupLive.FormComponent} |  | ||||||
|         id={@ammo_group.id || :new} |  | ||||||
|         title={@page_title} |  | ||||||
|         action={@live_action} |  | ||||||
|         ammo_group={@ammo_group} |  | ||||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} |  | ||||||
|         current_user={@current_user} |  | ||||||
|       /> |  | ||||||
|     </.modal> |  | ||||||
|   <% @live_action == :add_shot_group -> %> |  | ||||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> |  | ||||||
|       <.live_component |  | ||||||
|         module={CanneryWeb.Components.AddShotGroupComponent} |  | ||||||
|         id={:new} |  | ||||||
|         title={@page_title} |  | ||||||
|         action={@live_action} |  | ||||||
|         ammo_group={@ammo_group} |  | ||||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} |  | ||||||
|         current_user={@current_user} |  | ||||||
|       /> |  | ||||||
|     </.modal> |  | ||||||
|   <% @live_action == :move -> %> |  | ||||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> |  | ||||||
|       <.live_component |  | ||||||
|         module={CanneryWeb.Components.MoveAmmoGroupComponent} |  | ||||||
|         id={@ammo_group.id} |  | ||||||
|         title={@page_title} |  | ||||||
|         action={@live_action} |  | ||||||
|         ammo_group={@ammo_group} |  | ||||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} |  | ||||||
|         current_user={@current_user} |  | ||||||
|       /> |  | ||||||
|     </.modal> |  | ||||||
|   <% true -> %> |  | ||||||
|     <%= nil %> |  | ||||||
| <% end %> |  | ||||||
| @@ -1,147 +0,0 @@ | |||||||
| defmodule CanneryWeb.AmmoGroupLive.Show do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Liveview for showing and editing an Cannery.Ammo.AmmoGroup |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |  | ||||||
|   import CanneryWeb.Components.ContainerCard |  | ||||||
|   alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup, Repo} |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|   alias Phoenix.LiveView.Socket |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def mount(_params, _session, socket), do: {:ok, socket} |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_params( |  | ||||||
|         %{"id" => id, "shot_group_id" => shot_group_id}, |  | ||||||
|         _url, |  | ||||||
|         %{assigns: %{live_action: live_action, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     shot_group = ActivityLog.get_shot_group!(shot_group_id, current_user) |  | ||||||
|  |  | ||||||
|     socket = |  | ||||||
|       socket |  | ||||||
|       |> assign(page_title: page_title(live_action), shot_group: shot_group) |  | ||||||
|       |> display_ammo_group(id) |  | ||||||
|  |  | ||||||
|     {:noreply, socket} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: live_action}} = socket) do |  | ||||||
|     {:noreply, socket |> assign(page_title: page_title(live_action)) |> display_ammo_group(id)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp page_title(:add_shot_group), do: gettext("Record Shots") |  | ||||||
|   defp page_title(:edit_shot_group), do: gettext("Edit Shot Records") |  | ||||||
|   defp page_title(:move), do: gettext("Move Ammo") |  | ||||||
|   defp page_title(:show), do: gettext("Show Ammo") |  | ||||||
|   defp page_title(:edit), do: gettext("Edit Ammo") |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "delete", |  | ||||||
|         _params, |  | ||||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     ammo_group |> Ammo.delete_ammo_group!(current_user) |  | ||||||
|  |  | ||||||
|     prompt = dgettext("prompts", "Ammo deleted succesfully") |  | ||||||
|     redirect_to = Routes.ammo_group_index_path(socket, :index) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "toggle_staged", |  | ||||||
|         _params, |  | ||||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     {:ok, ammo_group} = |  | ||||||
|       ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> display_ammo_group(ammo_group)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "delete_shot_group", |  | ||||||
|         %{"id" => id}, |  | ||||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     {:ok, _} = |  | ||||||
|       ActivityLog.get_shot_group!(id, current_user) |  | ||||||
|       |> ActivityLog.delete_shot_group(current_user) |  | ||||||
|  |  | ||||||
|     prompt = dgettext("prompts", "Shot records deleted succesfully") |  | ||||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_group(ammo_group)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t() |  | ||||||
|   defp display_ammo_group(socket, %AmmoGroup{} = ammo_group) do |  | ||||||
|     ammo_group = ammo_group |> Repo.preload([:container, :ammo_type, :shot_groups], force: true) |  | ||||||
|  |  | ||||||
|     columns = [ |  | ||||||
|       %{label: gettext("Rounds shot"), key: :count}, |  | ||||||
|       %{label: gettext("Notes"), key: :notes}, |  | ||||||
|       %{label: gettext("Date"), key: :date}, |  | ||||||
|       %{label: nil, key: :actions, sortable: false} |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     rows = |  | ||||||
|       ammo_group.shot_groups |  | ||||||
|       |> Enum.map(fn shot_group -> |  | ||||||
|         ammo_group |> get_table_row_for_shot_group(shot_group, columns) |  | ||||||
|       end) |  | ||||||
|  |  | ||||||
|     socket |> assign(ammo_group: ammo_group, columns: columns, rows: rows) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id), |  | ||||||
|     do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user)) |  | ||||||
|  |  | ||||||
|   @spec get_table_row_for_shot_group(AmmoGroup.t(), ShotGroup.t(), [map()]) :: [map()] |  | ||||||
|   defp get_table_row_for_shot_group(ammo_group, %{date: date} = shot_group, columns) do |  | ||||||
|     assigns = %{ammo_group: ammo_group, shot_group: shot_group} |  | ||||||
|  |  | ||||||
|     columns |  | ||||||
|     |> Enum.into(%{}, fn %{key: key} -> |  | ||||||
|       value = |  | ||||||
|         case key do |  | ||||||
|           :date -> |  | ||||||
|             {date, date |> display_date()} |  | ||||||
|  |  | ||||||
|           :actions -> |  | ||||||
|             ~H""" |  | ||||||
|             <div class="px-4 py-2 space-x-4 flex justify-center items-center"> |  | ||||||
|               <.link |  | ||||||
|                 patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)} |  | ||||||
|                 class="text-primary-600 link" |  | ||||||
|                 data-qa={"edit-#{@shot_group.id}"} |  | ||||||
|               > |  | ||||||
|                 <i class="fa-fw fa-lg fas fa-edit"></i> |  | ||||||
|               </.link> |  | ||||||
|  |  | ||||||
|               <.link |  | ||||||
|                 href="#" |  | ||||||
|                 class="text-primary-600 link" |  | ||||||
|                 phx-click="delete_shot_group" |  | ||||||
|                 phx-value-id={@shot_group.id} |  | ||||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")} |  | ||||||
|                 data-qa={"delete-#{@shot_group.id}"} |  | ||||||
|               > |  | ||||||
|                 <i class="fa-fw fa-lg fas fa-trash"></i> |  | ||||||
|               </.link> |  | ||||||
|             </div> |  | ||||||
|             """ |  | ||||||
|  |  | ||||||
|           key -> |  | ||||||
|             shot_group |> Map.get(key) |  | ||||||
|         end |  | ||||||
|  |  | ||||||
|       {key, value} |  | ||||||
|     end) |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -1,161 +0,0 @@ | |||||||
| <div> |  | ||||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> |  | ||||||
|     <%= @title %> |  | ||||||
|   </h2> |  | ||||||
|   <.form |  | ||||||
|     :let={f} |  | ||||||
|     for={@changeset} |  | ||||||
|     id="ammo_type-form" |  | ||||||
|     phx-target={@myself} |  | ||||||
|     phx-change="validate" |  | ||||||
|     phx-submit="save" |  | ||||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" |  | ||||||
|   > |  | ||||||
|     <%= if @changeset.action && not @changeset.valid? do %> |  | ||||||
|       <div class="invalid-feedback col-span-3 text-center"> |  | ||||||
|         <%= changeset_errors(@changeset) %> |  | ||||||
|       </div> |  | ||||||
|     <% end %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %> |  | ||||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= textarea(f, :desc, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       phx_hook: "MaintainAttrs" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :desc, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <a |  | ||||||
|       href="https://en.wikipedia.org/wiki/Bullet#Abbreviations" |  | ||||||
|       class="col-span-3 text-center link title text-md text-primary-600" |  | ||||||
|     > |  | ||||||
|       <%= gettext("Example bullet type abbreviations") %> |  | ||||||
|     </a> |  | ||||||
|     <%= label(f, :bullet_type, gettext("Bullet type"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :bullet_type, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: gettext("FMJ") |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :bullet_type, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :bullet_core, gettext("Bullet core"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :bullet_core, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: gettext("Steel") |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :bullet_core, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :cartridge, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: "5.56x46mm NATO" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :cartridge, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :caliber, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: ".223" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :caliber, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :case_material, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: gettext("Brass") |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :jacket_type, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: gettext("Bimetal") |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :muzzle_velocity, gettext("Muzzle velocity"), |  | ||||||
|       class: "title text-lg text-primary-600" |  | ||||||
|     ) %> |  | ||||||
|     <%= number_input(f, :muzzle_velocity, |  | ||||||
|       step: "1", |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       min: 1 |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :powder_type, class: "text-center col-span-2 input input-primary") %> |  | ||||||
|     <%= error_tag(f, :powder_type, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"), |  | ||||||
|       class: "title text-lg text-primary-600" |  | ||||||
|     ) %> |  | ||||||
|     <%= number_input(f, :powder_grains_per_charge, |  | ||||||
|       step: "1", |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       min: 1 |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :grains, gettext("Grains"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= number_input(f, :grains, |  | ||||||
|       step: "1", |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       min: 1 |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :grains, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :pressure, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: "+P" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :pressure, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :primer_type, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: "Boxer" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :primer_type, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :firing_type, |  | ||||||
|       class: "text-center col-span-2 input input-primary", |  | ||||||
|       placeholder: "Centerfire" |  | ||||||
|     ) %> |  | ||||||
|     <%= error_tag(f, :firing_type, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :tracer, gettext("Tracer"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= checkbox(f, :tracer, class: "text-center col-span-2 checkbox") %> |  | ||||||
|     <%= error_tag(f, :tracer, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :incendiary, gettext("Incendiary"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= checkbox(f, :incendiary, class: "text-center col-span-2 checkbox") %> |  | ||||||
|     <%= error_tag(f, :incendiary, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :blank, gettext("Blank"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= checkbox(f, :blank, class: "text-center col-span-2 checkbox") %> |  | ||||||
|     <%= error_tag(f, :blank, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :corrosive, gettext("Corrosive"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= checkbox(f, :corrosive, class: "text-center col-span-2 checkbox") %> |  | ||||||
|     <%= error_tag(f, :corrosive, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :manufacturer, gettext("Manufacturer"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :manufacturer, class: "text-center col-span-2 input input-primary") %> |  | ||||||
|     <%= error_tag(f, :manufacturer, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :upc, gettext("UPC"), class: "title text-lg text-primary-600") %> |  | ||||||
|     <%= text_input(f, :upc, class: "text-center col-span-2 input input-primary") %> |  | ||||||
|     <%= error_tag(f, :upc, "col-span-3 text-center") %> |  | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Save"), |  | ||||||
|       phx_disable_with: dgettext("prompts", "Saving..."), |  | ||||||
|       class: "mx-auto col-span-3 btn btn-primary" |  | ||||||
|     ) %> |  | ||||||
|   </.form> |  | ||||||
| </div> |  | ||||||
| @@ -1,249 +0,0 @@ | |||||||
| defmodule CanneryWeb.AmmoTypeLive.Index do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Liveview for showing a Cannery.Ammo.AmmoType index |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |  | ||||||
|  |  | ||||||
|   alias Cannery.{Ammo, Ammo.AmmoType} |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def mount(_params, _session, socket) do |  | ||||||
|     {:ok, socket |> assign(:show_used, false) |> list_ammo_types()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do |  | ||||||
|     {:noreply, apply_action(socket, live_action, params)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("Edit Ammo type")) |  | ||||||
|     |> assign(:ammo_type, Ammo.get_ammo_type!(id, current_user)) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("New Ammo type")) |  | ||||||
|     |> assign(:ammo_type, %{Ammo.get_ammo_type!(id, current_user) | id: nil}) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(socket, :new, _params) do |  | ||||||
|     socket |  | ||||||
|     |> assign(:page_title, gettext("New Ammo type")) |  | ||||||
|     |> assign(:ammo_type, %AmmoType{}) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp apply_action(socket, :index, _params) do |  | ||||||
|     socket |> assign(:page_title, gettext("Ammo types")) |> assign(:ammo_type, nil) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do |  | ||||||
|     %{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user) |  | ||||||
|  |  | ||||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: name) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do |  | ||||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp list_ammo_types(%{assigns: %{current_user: current_user, show_used: show_used}} = socket) do |  | ||||||
|     ammo_types = Ammo.list_ammo_types(current_user) |  | ||||||
|  |  | ||||||
|     columns = |  | ||||||
|       [ |  | ||||||
|         %{label: gettext("Name"), key: :name, type: :name}, |  | ||||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, |  | ||||||
|         %{label: gettext("Bullet core"), key: :bullet_core, type: :string}, |  | ||||||
|         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, |  | ||||||
|         %{label: gettext("Caliber"), key: :caliber, type: :string}, |  | ||||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, |  | ||||||
|         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, |  | ||||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, |  | ||||||
|         %{label: gettext("Powder type"), key: :powder_type, type: :string}, |  | ||||||
|         %{ |  | ||||||
|           label: gettext("Powder grains per charge"), |  | ||||||
|           key: :powder_grains_per_charge, |  | ||||||
|           type: :string |  | ||||||
|         }, |  | ||||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, |  | ||||||
|         %{label: gettext("Pressure"), key: :pressure, type: :string}, |  | ||||||
|         %{label: gettext("Primer type"), key: :primer_type, type: :string}, |  | ||||||
|         %{label: gettext("Firing type"), key: :firing_type, type: :string}, |  | ||||||
|         %{label: gettext("Tracer"), key: :tracer, type: :boolean}, |  | ||||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :boolean}, |  | ||||||
|         %{label: gettext("Blank"), key: :blank, type: :boolean}, |  | ||||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :boolean}, |  | ||||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string}, |  | ||||||
|         %{label: gettext("UPC"), key: "upc", type: :string} |  | ||||||
|       ] |  | ||||||
|       |> Enum.filter(fn %{key: key, type: type} -> |  | ||||||
|         # remove columns if all values match defaults |  | ||||||
|         default_value = if type == :boolean, do: false, else: nil |  | ||||||
|  |  | ||||||
|         ammo_types |  | ||||||
|         |> Enum.any?(fn ammo_type -> |  | ||||||
|           not (ammo_type |> Map.get(key) == default_value) |  | ||||||
|         end) |  | ||||||
|       end) |  | ||||||
|       |> Kernel.++([ |  | ||||||
|         %{label: gettext("Rounds"), key: :round_count, type: :round_count} |  | ||||||
|       ]) |  | ||||||
|       |> Kernel.++( |  | ||||||
|         if show_used do |  | ||||||
|           [ |  | ||||||
|             %{ |  | ||||||
|               label: gettext("Used rounds"), |  | ||||||
|               key: :used_round_count, |  | ||||||
|               type: :used_round_count |  | ||||||
|             }, |  | ||||||
|             %{ |  | ||||||
|               label: gettext("Total ever rounds"), |  | ||||||
|               key: :historical_round_count, |  | ||||||
|               type: :historical_round_count |  | ||||||
|             } |  | ||||||
|           ] |  | ||||||
|         else |  | ||||||
|           [] |  | ||||||
|         end |  | ||||||
|       ) |  | ||||||
|       |> Kernel.++([%{label: gettext("Packs"), key: :ammo_count, type: :ammo_count}]) |  | ||||||
|       |> Kernel.++( |  | ||||||
|         if show_used do |  | ||||||
|           [ |  | ||||||
|             %{ |  | ||||||
|               label: gettext("Used packs"), |  | ||||||
|               key: :used_ammo_count, |  | ||||||
|               type: :used_ammo_count |  | ||||||
|             }, |  | ||||||
|             %{ |  | ||||||
|               label: gettext("Total ever packs"), |  | ||||||
|               key: :historical_ammo_count, |  | ||||||
|               type: :historical_ammo_count |  | ||||||
|             } |  | ||||||
|           ] |  | ||||||
|         else |  | ||||||
|           [] |  | ||||||
|         end |  | ||||||
|       ) |  | ||||||
|       |> Kernel.++([ |  | ||||||
|         %{label: gettext("Average Price paid"), key: :avg_price_paid, type: :avg_price_paid}, |  | ||||||
|         %{label: nil, key: "actions", type: :actions, sortable: false} |  | ||||||
|       ]) |  | ||||||
|  |  | ||||||
|     rows = |  | ||||||
|       ammo_types |  | ||||||
|       |> Enum.map(fn ammo_type -> ammo_type |> get_ammo_type_values(columns, current_user) end) |  | ||||||
|  |  | ||||||
|     socket |> assign(columns: columns, rows: rows) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_values(ammo_type, columns, current_user) do |  | ||||||
|     columns |  | ||||||
|     |> Enum.into(%{}, fn %{key: key, type: type} -> |  | ||||||
|       {key, get_ammo_type_value(type, key, ammo_type, current_user)} |  | ||||||
|     end) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:boolean, key, ammo_type, _current_user), |  | ||||||
|     do: ammo_type |> Map.get(key) |> humanize() |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:round_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:historical_round_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:used_round_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:ammo_count, _key, ammo_type, current_user), |  | ||||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user) |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, current_user) do |  | ||||||
|     case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do |  | ||||||
|       nil -> gettext("No cost information") |  | ||||||
|       count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2)) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:name, _key, ammo_type, _current_user) do |  | ||||||
|     assigns = %{ammo_type: ammo_type} |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <.link |  | ||||||
|       navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} |  | ||||||
|       class="link" |  | ||||||
|       data-qa={"view-name-#{@ammo_type.id}"} |  | ||||||
|     > |  | ||||||
|       <%= @ammo_type.name %> |  | ||||||
|     </.link> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(:actions, _key, ammo_type, _current_user) do |  | ||||||
|     assigns = %{ammo_type: ammo_type} |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <div class="px-4 py-2 space-x-4 flex justify-center items-center"> |  | ||||||
|       <.link |  | ||||||
|         navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"view-#{@ammo_type.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-eye"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         patch={Routes.ammo_type_index_path(Endpoint, :edit, @ammo_type)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"edit-#{@ammo_type.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         patch={Routes.ammo_type_index_path(Endpoint, :clone, @ammo_type)} |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         data-qa={"clone-#{@ammo_type.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-fw fa-lg fas fa-copy"></i> |  | ||||||
|       </.link> |  | ||||||
|  |  | ||||||
|       <.link |  | ||||||
|         href="#" |  | ||||||
|         class="text-primary-600 link" |  | ||||||
|         phx-click="delete" |  | ||||||
|         phx-value-id={@ammo_type.id} |  | ||||||
|         data-confirm={ |  | ||||||
|           dgettext( |  | ||||||
|             "prompts", |  | ||||||
|             "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", |  | ||||||
|             name: @ammo_type.name |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|         data-qa={"delete-#{@ammo_type.id}"} |  | ||||||
|       > |  | ||||||
|         <i class="fa-lg fas fa-trash"></i> |  | ||||||
|       </.link> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(nil, _key, _ammo_type, _current_user), do: nil |  | ||||||
|  |  | ||||||
|   defp get_ammo_type_value(_other, key, ammo_type, _current_user), do: ammo_type |> Map.get(key) |  | ||||||
| end |  | ||||||
| @@ -1,51 +0,0 @@ | |||||||
| <div class="flex flex-col space-y-8 justify-center items-center"> |  | ||||||
|   <h1 class="title text-2xl title-primary-500"> |  | ||||||
|     <%= gettext("Catalog") %> |  | ||||||
|   </h1> |  | ||||||
|  |  | ||||||
|   <%= if @rows |> Enum.empty?() do %> |  | ||||||
|     <h2 class="title text-xl text-primary-600"> |  | ||||||
|       <%= gettext("No Ammo Types") %> |  | ||||||
|       <%= display_emoji("😔") %> |  | ||||||
|     </h2> |  | ||||||
|  |  | ||||||
|     <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|       <%= dgettext("actions", "Add your first type!") %> |  | ||||||
|     </.link> |  | ||||||
|   <% else %> |  | ||||||
|     <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> |  | ||||||
|       <%= dgettext("actions", "New Ammo type") %> |  | ||||||
|     </.link> |  | ||||||
|  |  | ||||||
|     <div class="flex flex-col justify-center items-center"> |  | ||||||
|       <.toggle_button action="toggle_show_used" value={@show_used}> |  | ||||||
|         <span class="title text-lg text-primary-600"> |  | ||||||
|           <%= gettext("Show used") %> |  | ||||||
|         </span> |  | ||||||
|       </.toggle_button> |  | ||||||
|     </div> |  | ||||||
|  |  | ||||||
|     <.live_component |  | ||||||
|       module={CanneryWeb.Components.TableComponent} |  | ||||||
|       id="ammo_types_index_table" |  | ||||||
|       action={@live_action} |  | ||||||
|       columns={@columns} |  | ||||||
|       rows={@rows} |  | ||||||
|     /> |  | ||||||
|   <% end %> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <%= if @live_action in [:new, :edit, :clone] do %> |  | ||||||
|   <.modal return_to={Routes.ammo_type_index_path(Endpoint, :index)}> |  | ||||||
|     <.live_component |  | ||||||
|       module={CanneryWeb.AmmoTypeLive.FormComponent} |  | ||||||
|       id={@ammo_type.id || :new} |  | ||||||
|       title={@page_title} |  | ||||||
|       action={@live_action} |  | ||||||
|       ammo_type={@ammo_type} |  | ||||||
|       return_to={Routes.ammo_type_index_path(Endpoint, :index)} |  | ||||||
|       current_user={@current_user} |  | ||||||
|       } |  | ||||||
|     /> |  | ||||||
|   </.modal> |  | ||||||
| <% end %> |  | ||||||
| @@ -1,64 +0,0 @@ | |||||||
| defmodule CanneryWeb.AmmoTypeLive.Show do |  | ||||||
|   @moduledoc """ |  | ||||||
|   Liveview for showing and editing an Cannery.Ammo.AmmoType |  | ||||||
|   """ |  | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |  | ||||||
|   import CanneryWeb.Components.AmmoGroupCard |  | ||||||
|   alias Cannery.Ammo |  | ||||||
|   alias CanneryWeb.Endpoint |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def mount(_params, _session, socket), do: {:ok, socket |> assign(show_used: false)} |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_params(%{"id" => id}, _params, %{assigns: %{current_user: current_user}} = socket) do |  | ||||||
|     ammo_type = Ammo.get_ammo_type!(id, current_user) |  | ||||||
|     {:noreply, socket |> display_ammo_type(ammo_type)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |  | ||||||
|         "delete", |  | ||||||
|         _params, |  | ||||||
|         %{assigns: %{ammo_type: ammo_type, current_user: current_user}} = socket |  | ||||||
|       ) do |  | ||||||
|     %{name: ammo_type_name} = ammo_type |> Ammo.delete_ammo_type!(current_user) |  | ||||||
|  |  | ||||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: ammo_type_name) |  | ||||||
|     redirect_to = Routes.ammo_type_index_path(socket, :index) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do |  | ||||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp display_ammo_type( |  | ||||||
|          %{ |  | ||||||
|            assigns: %{ |  | ||||||
|              live_action: live_action, |  | ||||||
|              current_user: current_user, |  | ||||||
|              show_used: show_used |  | ||||||
|            } |  | ||||||
|          } = socket, |  | ||||||
|          ammo_type |  | ||||||
|        ) do |  | ||||||
|     socket |  | ||||||
|     |> assign( |  | ||||||
|       page_title: page_title(live_action), |  | ||||||
|       ammo_type: ammo_type, |  | ||||||
|       ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used), |  | ||||||
|       avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) |  | ||||||
|     ) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp display_ammo_type(%{assigns: %{ammo_type: ammo_type}} = socket) do |  | ||||||
|     socket |> display_ammo_type(ammo_type) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp page_title(:show), do: gettext("Show Ammo type") |  | ||||||
|   defp page_title(:edit), do: gettext("Edit Ammo type") |  | ||||||
| end |  | ||||||
| @@ -1,191 +0,0 @@ | |||||||
| <div class="space-y-4 flex flex-col justify-center items-center"> |  | ||||||
|   <h1 class="title text-2xl title-primary-500"> |  | ||||||
|     <%= @ammo_type.name %> |  | ||||||
|   </h1> |  | ||||||
|  |  | ||||||
|   <%= if @ammo_type.desc do %> |  | ||||||
|     <span class="max-w-2xl w-full px-8 py-4 rounded-lg |  | ||||||
|       text-center title text-lg |  | ||||||
|       border border-primary-600"> |  | ||||||
|       <%= @ammo_type.desc %> |  | ||||||
|     </span> |  | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> |  | ||||||
|     <.link |  | ||||||
|       patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)} |  | ||||||
|       class="text-primary-600 link" |  | ||||||
|       data-qa="edit" |  | ||||||
|     > |  | ||||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> |  | ||||||
|     </.link> |  | ||||||
|  |  | ||||||
|     <.link |  | ||||||
|       href="#" |  | ||||||
|       class="text-primary-600 link" |  | ||||||
|       phx-click="delete" |  | ||||||
|       data-confirm={ |  | ||||||
|         dgettext( |  | ||||||
|           "prompts", |  | ||||||
|           "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", |  | ||||||
|           name: @ammo_type.name |  | ||||||
|         ) |  | ||||||
|       } |  | ||||||
|       data-qa="delete" |  | ||||||
|     > |  | ||||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> |  | ||||||
|     </.link> |  | ||||||
|   </div> |  | ||||||
|  |  | ||||||
|   <hr class="hr" /> |  | ||||||
|  |  | ||||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> |  | ||||||
|     <%= for {field_name, field, type} <- [ |  | ||||||
|           {gettext("Bullet type"), :bullet_type, :string}, |  | ||||||
|           {gettext("Bullet core"), :bullet_core, :string}, |  | ||||||
|           {gettext("Cartridge"), :cartridge, :string}, |  | ||||||
|           {gettext("Caliber"), :caliber, :string}, |  | ||||||
|           {gettext("Case material"), :case_material, :string}, |  | ||||||
|           {gettext("Jacket type"), :jacket_type, :string}, |  | ||||||
|           {gettext("Muzzle velocity"), :muzzle_velocity, :string}, |  | ||||||
|           {gettext("Powder type"), :powder_type, :string}, |  | ||||||
|           {gettext("Powder grains per charge"), :powder_grains_per_charge, :string}, |  | ||||||
|           {gettext("Grains"), :grains, :string}, |  | ||||||
|           {gettext("Pressure"), :pressure, :string}, |  | ||||||
|           {gettext("Primer type"), :primer_type, :string}, |  | ||||||
|           {gettext("Firing type"), :firing_type, :string}, |  | ||||||
|           {gettext("Tracer"), :tracer, :boolean}, |  | ||||||
|           {gettext("Incendiary"), :incendiary, :boolean}, |  | ||||||
|           {gettext("Blank"), :blank, :boolean}, |  | ||||||
|           {gettext("Corrosive"), :corrosive, :boolean}, |  | ||||||
|           {gettext("Manufacturer"), :manufacturer, :string}, |  | ||||||
|           {gettext("UPC"), :upc, :string} |  | ||||||
|         ] do %> |  | ||||||
|       <%= if @ammo_type |> Map.get(field) do %> |  | ||||||
|         <h3 class="title text-lg"> |  | ||||||
|           <%= field_name %>: |  | ||||||
|         </h3> |  | ||||||
|  |  | ||||||
|         <span class="text-primary-600"> |  | ||||||
|           <%= case type do %> |  | ||||||
|             <% :boolean -> %> |  | ||||||
|               <%= @ammo_type |> Map.get(field) |> humanize() %> |  | ||||||
|             <% _ -> %> |  | ||||||
|               <%= @ammo_type |> Map.get(field) %> |  | ||||||
|           <% end %> |  | ||||||
|         </span> |  | ||||||
|       <% end %> |  | ||||||
|     <% end %> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Rounds:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_round_count_for_ammo_type(@current_user) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Used rounds:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_used_count_for_ammo_type(@current_user) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Total ever rounds:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_historical_count_for_ammo_type(@current_user) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Packs:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Used packs:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_used_ammo_groups_count_for_type(@current_user) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Total ever packs:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user, true) %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <h3 class="title text-lg"> |  | ||||||
|       <%= gettext("Added on:") %> |  | ||||||
|     </h3> |  | ||||||
|  |  | ||||||
|     <span class="text-primary-600"> |  | ||||||
|       <%= @ammo_type.inserted_at |> display_datetime() %> |  | ||||||
|     </span> |  | ||||||
|  |  | ||||||
|     <%= if @avg_cost_per_round do %> |  | ||||||
|       <h3 class="title text-lg"> |  | ||||||
|         <%= gettext("Average Price paid") %>: |  | ||||||
|       </h3> |  | ||||||
|  |  | ||||||
|       <span class="text-primary-600"> |  | ||||||
|         <%= gettext("$%{amount}", |  | ||||||
|           amount: @avg_cost_per_round |> :erlang.float_to_binary(decimals: 2) |  | ||||||
|         ) %> |  | ||||||
|       </span> |  | ||||||
|     <% else %> |  | ||||||
|       <h3 class="mx-8 my-4 title text-lg text-primary-600 col-span-2"> |  | ||||||
|         <%= gettext("No cost information") %> |  | ||||||
|       </h3> |  | ||||||
|     <% end %> |  | ||||||
|   </div> |  | ||||||
|  |  | ||||||
|   <hr class="hr" /> |  | ||||||
|  |  | ||||||
|   <div class="flex flex-col justify-center items-center"> |  | ||||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> |  | ||||||
|       <span class="title text-lg text-primary-600"> |  | ||||||
|         <%= gettext("Show used") %> |  | ||||||
|       </span> |  | ||||||
|     </.toggle_button> |  | ||||||
|   </div> |  | ||||||
|  |  | ||||||
|   <div> |  | ||||||
|     <%= if @ammo_groups |> Enum.empty?() do %> |  | ||||||
|       <h2 class="mx-8 my-4 title text-lg text-primary-600"> |  | ||||||
|         <%= gettext("No ammo for this type") %> |  | ||||||
|         <%= display_emoji("😔") %> |  | ||||||
|       </h2> |  | ||||||
|     <% else %> |  | ||||||
|       <div class="flex flex-wrap justify-center items-center"> |  | ||||||
|         <%= for ammo_group <- @ammo_groups do %> |  | ||||||
|           <.ammo_group_card ammo_group={ammo_group} show_container={true} /> |  | ||||||
|         <% end %> |  | ||||||
|       </div> |  | ||||||
|     <% end %> |  | ||||||
|   </div> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <%= if @live_action in [:edit] do %> |  | ||||||
|   <.modal return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}> |  | ||||||
|     <.live_component |  | ||||||
|       module={CanneryWeb.AmmoTypeLive.FormComponent} |  | ||||||
|       id={@ammo_type.id} |  | ||||||
|       title={@page_title} |  | ||||||
|       action={@live_action} |  | ||||||
|       ammo_type={@ammo_type} |  | ||||||
|       return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} |  | ||||||
|       current_user={@current_user} |  | ||||||
|     /> |  | ||||||
|   </.modal> |  | ||||||
| <% end %> |  | ||||||
| @@ -4,25 +4,41 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |   use CanneryWeb, :live_component | ||||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo, Tags, Tags.Tag} |   alias Cannery.{Accounts.User, Containers} | ||||||
|  |   alias Cannery.Containers.{Container, Tag} | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   @spec update( |   @spec update( | ||||||
|           %{:container => Container.t(), :current_user => User.t(), optional(any) => any}, |           %{ | ||||||
|  |             :container => Container.t(), | ||||||
|  |             :current_path => String.t(), | ||||||
|  |             :current_user => User.t(), | ||||||
|  |             optional(any) => any | ||||||
|  |           }, | ||||||
|           Socket.t() |           Socket.t() | ||||||
|         ) :: {:ok, Socket.t()} |         ) :: {:ok, Socket.t()} | ||||||
|   def update(%{container: container, current_user: current_user} = assigns, socket) do |   def update( | ||||||
|     tags = Tags.list_tags(current_user) |         %{container: _container, current_path: _current_path, current_user: current_user} = | ||||||
|     container = container |> Repo.preload(:tags) |           assigns, | ||||||
|     {:ok, socket |> assign(assigns) |> assign(tags: tags, container: container)} |         socket | ||||||
|  |       ) do | ||||||
|  |     tags = Containers.list_tags(current_user) | ||||||
|  |     {:ok, socket |> assign(assigns) |> assign(:tags, tags)} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def handle_event( |   def handle_event( | ||||||
|         "save", |         "save", | ||||||
|         %{"tag" => %{"tag_id" => tag_id}}, |         %{"tag" => %{"tag_id" => tag_id}}, | ||||||
|         %{assigns: %{tags: tags, container: container, current_user: current_user}} = socket |         %{ | ||||||
|  |           assigns: %{ | ||||||
|  |             tags: tags, | ||||||
|  |             container: container, | ||||||
|  |             current_user: current_user, | ||||||
|  |             current_path: current_path | ||||||
|  |           } | ||||||
|  |         } = socket | ||||||
|       ) do |       ) do | ||||||
|     socket = |     socket = | ||||||
|       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do |       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do | ||||||
| @@ -32,19 +48,24 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | |||||||
|  |  | ||||||
|         %{name: tag_name} = tag -> |         %{name: tag_name} = tag -> | ||||||
|           _container_tag = Containers.add_tag!(container, tag, current_user) |           _container_tag = Containers.add_tag!(container, tag, current_user) | ||||||
|           container = container |> Repo.preload(:tags, force: true) |  | ||||||
|           prompt = dgettext("prompts", "%{name} added successfully", name: tag_name) |           prompt = dgettext("prompts", "%{name} added successfully", name: tag_name) | ||||||
|           socket |> put_flash(:info, prompt) |> assign(container: container) |           socket |> put_flash(:info, prompt) |> push_patch(to: current_path) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     {:noreply, socket} |     {:noreply, socket} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |   def handle_event( | ||||||
|         "delete", |         "delete", | ||||||
|         %{"tag-id" => tag_id}, |         %{"tag-id" => tag_id}, | ||||||
|         %{assigns: %{tags: tags, container: container, current_user: current_user}} = socket |         %{ | ||||||
|  |           assigns: %{ | ||||||
|  |             tags: tags, | ||||||
|  |             container: container, | ||||||
|  |             current_user: current_user, | ||||||
|  |             current_path: current_path | ||||||
|  |           } | ||||||
|  |         } = socket | ||||||
|       ) do |       ) do | ||||||
|     socket = |     socket = | ||||||
|       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do |       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do | ||||||
| @@ -54,9 +75,8 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | |||||||
|  |  | ||||||
|         %{name: tag_name} = tag -> |         %{name: tag_name} = tag -> | ||||||
|           _container_tag = Containers.remove_tag!(container, tag, current_user) |           _container_tag = Containers.remove_tag!(container, tag, current_user) | ||||||
|           container = container |> Repo.preload(:tags, force: true) |  | ||||||
|           prompt = dgettext("prompts", "%{name} removed successfully", name: tag_name) |           prompt = dgettext("prompts", "%{name} removed successfully", name: tag_name) | ||||||
|           socket |> put_flash(:info, prompt) |> assign(container: container) |           socket |> put_flash(:info, prompt) |> push_patch(to: current_path) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     {:noreply, socket} |     {:noreply, socket} | ||||||
|   | |||||||
| @@ -4,8 +4,8 @@ | |||||||
|   </h2> |   </h2> | ||||||
|  |  | ||||||
|   <div class="flex flex-wrap justify-center items-center"> |   <div class="flex flex-wrap justify-center items-center"> | ||||||
|     <%= for tag <- @container.tags do %> |  | ||||||
|     <.link |     <.link | ||||||
|  |       :for={tag <- @container.tags} | ||||||
|       href="#" |       href="#" | ||||||
|       class="mx-2 my-1 px-4 py-2 rounded-lg title text-xl" |       class="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}"} | ||||||
| @@ -24,14 +24,11 @@ | |||||||
|       <%= tag.name %> |       <%= tag.name %> | ||||||
|       <i class="fa-fw fa-sm fas fa-trash"></i> |       <i class="fa-fw fa-sm fas fa-trash"></i> | ||||||
|     </.link> |     </.link> | ||||||
|     <% end %> |  | ||||||
|  |  | ||||||
|     <%= if @container.tags |> Enum.empty?() do %> |     <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600"> | ||||||
|       <h2 class="title text-xl text-primary-600"> |  | ||||||
|       <%= gettext("No tags") %> |       <%= gettext("No tags") %> | ||||||
|       <%= display_emoji("😔") %> |       <%= display_emoji("😔") %> | ||||||
|     </h2> |     </h2> | ||||||
|     <% end %> |  | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <%= unless tag_options(@tags, @container) |> Enum.empty?() do %> |   <%= unless tag_options(@tags, @container) |> Enum.empty?() do %> | ||||||
| @@ -39,7 +36,8 @@ | |||||||
|  |  | ||||||
|     <.form |     <.form | ||||||
|       :let={f} |       :let={f} | ||||||
|       for={:tag} |       for={%{}} | ||||||
|  |       as={:tag} | ||||||
|       id="add-tag-to-container-form" |       id="add-tag-to-container-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" |       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-target={@myself} | ||||||
|   | |||||||
| @@ -35,17 +35,17 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | |||||||
|          container_params |          container_params | ||||||
|        ) do |        ) do | ||||||
|     changeset_action = |     changeset_action = | ||||||
|       cond do |       case action do | ||||||
|         action in [:new, :clone] -> :insert |         create when create in [:new, :clone] -> :insert | ||||||
|         action == :edit -> :update |         :edit -> :update | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     changeset = |     changeset = | ||||||
|       cond do |       case action do | ||||||
|         action in [:new, :clone] -> |         create when create in [:new, :clone] -> | ||||||
|           container |> Container.create_changeset(user, container_params) |           container |> Container.create_changeset(user, container_params) | ||||||
|  |  | ||||||
|         action == :edit -> |         :edit -> | ||||||
|           container |> Container.update_changeset(container_params) |           container |> Container.update_changeset(container_params) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,39 +11,46 @@ | |||||||
|     phx-change="validate" |     phx-change="validate" | ||||||
|     phx-submit="save" |     phx-submit="save" | ||||||
|   > |   > | ||||||
|     <%= if @changeset.action && not @changeset.valid? do %> |     <div | ||||||
|       <div class="invalid-feedback col-span-3 text-center"> |       :if={@changeset.action && not @changeset.valid?()} | ||||||
|  |       class="invalid-feedback col-span-3 text-center" | ||||||
|  |     > | ||||||
|       <%= changeset_errors(@changeset) %> |       <%= changeset_errors(@changeset) %> | ||||||
|     </div> |     </div> | ||||||
|     <% end %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> |     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= text_input(f, :name, |     <%= text_input(f, :name, | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|       placeholder: gettext("My cool ammo can") |       placeholder: gettext("My cool ammo can"), | ||||||
|  |       maxlength: 255 | ||||||
|     ) %> |     ) %> | ||||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> |     <%= error_tag(f, :name, "col-span-3 text-center") %> | ||||||
|  |  | ||||||
|     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> |     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= textarea(f, :desc, |     <%= textarea(f, :desc, | ||||||
|  |       id: "container-form-desc", | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|  |       placeholder: gettext("Metal ammo can with the anime girl sticker"), | ||||||
|       phx_hook: "MaintainAttrs", |       phx_hook: "MaintainAttrs", | ||||||
|       placeholder: gettext("Metal ammo can with the anime girl sticker") |       phx_update: "ignore" | ||||||
|     ) %> |     ) %> | ||||||
|     <%= error_tag(f, :desc, "col-span-3 text-center") %> |     <%= error_tag(f, :desc, "col-span-3 text-center") %> | ||||||
|  |  | ||||||
|     <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> |     <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= text_input(f, :type, |     <%= text_input(f, :type, | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|       placeholder: gettext("Magazine, Clip, Ammo Box, etc") |       placeholder: gettext("Magazine, Clip, Ammo Box, etc"), | ||||||
|  |       maxlength: 255 | ||||||
|     ) %> |     ) %> | ||||||
|     <%= error_tag(f, :type, "col-span-3 text-center") %> |     <%= error_tag(f, :type, "col-span-3 text-center") %> | ||||||
|  |  | ||||||
|     <%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> |     <%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> | ||||||
|     <%= textarea(f, :location, |     <%= textarea(f, :location, | ||||||
|  |       id: "container-form-location", | ||||||
|       class: "input input-primary col-span-2", |       class: "input input-primary col-span-2", | ||||||
|  |       placeholder: gettext("On the bookshelf"), | ||||||
|       phx_hook: "MaintainAttrs", |       phx_hook: "MaintainAttrs", | ||||||
|       placeholder: gettext("On the bookshelf") |       phx_update: "ignore" | ||||||
|     ) %> |     ) %> | ||||||
|     <%= error_tag(f, :location, "col-span-3 text-center") %> |     <%= error_tag(f, :location, "col-span-3 text-center") %> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,13 +4,17 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |   use CanneryWeb, :live_view | ||||||
|   import CanneryWeb.Components.ContainerCard |   alias Cannery.{Containers, Containers.Container} | ||||||
|   alias Cannery.{Containers, Containers.Container, Repo} |  | ||||||
|   alias CanneryWeb.{Components.TagCard, Endpoint} |  | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def mount(_params, _session, socket), do: {:ok, socket |> assign(view_table: false)} |   def mount(%{"search" => search}, _session, socket) do | ||||||
|  |     {:ok, socket |> assign(view_table: true, search: search) |> display_containers()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     {:ok, socket |> assign(view_table: true, search: nil) |> display_containers()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do |   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||||
| @@ -18,10 +22,7 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do |   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||||
|     %{name: container_name} = |     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||||
|       container = |  | ||||||
|       Containers.get_container!(id, current_user) |  | ||||||
|       |> Repo.preload([:tags, :ammo_groups], force: true) |  | ||||||
|  |  | ||||||
|     socket |     socket | ||||||
|     |> assign(page_title: gettext("Edit %{name}", name: container_name), container: container) |     |> assign(page_title: gettext("Edit %{name}", name: container_name), container: container) | ||||||
| @@ -42,25 +43,22 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|     socket |     socket | ||||||
|     |> assign( |     |> assign( | ||||||
|       page_title: gettext("Containers"), |       page_title: gettext("Containers"), | ||||||
|       container: nil |       container: nil, | ||||||
|  |       search: nil | ||||||
|     ) |     ) | ||||||
|     |> display_containers() |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp apply_action(socket, :table, _params) do |   defp apply_action(socket, :search, %{"search" => search}) do | ||||||
|     socket |     socket | ||||||
|     |> assign( |     |> assign( | ||||||
|       page_title: gettext("Containers"), |       page_title: gettext("Containers"), | ||||||
|       container: nil, |       container: nil, | ||||||
|       view_table: true |       search: search | ||||||
|     ) |     ) | ||||||
|     |> display_containers() |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do |   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do | ||||||
|     %{name: container_name} = |     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||||
|       container = |  | ||||||
|       Containers.get_container!(id, current_user) |> Repo.preload([:tags, :ammo_groups]) |  | ||||||
|  |  | ||||||
|     page_title = gettext("Edit %{name} tags", name: container_name) |     page_title = gettext("Edit %{name} tags", name: container_name) | ||||||
|     socket |> assign(page_title: page_title, container: container) |     socket |> assign(page_title: page_title, container: container) | ||||||
| @@ -81,15 +79,15 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|               prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) |               prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) | ||||||
|               socket |> put_flash(:info, prompt) |> display_containers() |               socket |> put_flash(:info, prompt) |> display_containers() | ||||||
|  |  | ||||||
|             {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} -> |             {:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> | ||||||
|               ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ") |               packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") | ||||||
|  |  | ||||||
|               prompt = |               prompt = | ||||||
|                 dgettext( |                 dgettext( | ||||||
|                   "errors", |                   "errors", | ||||||
|                   "Could not delete %{name}: %{error}", |                   "Could not delete %{name}: %{error}", | ||||||
|                   name: changeset |> Changeset.get_field(:name, "container"), |                   name: changeset |> Changeset.get_field(:name, "container"), | ||||||
|                   error: ammo_groups_error |                   error: packs_error | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|               socket |> put_flash(:error, prompt) |               socket |> put_flash(:error, prompt) | ||||||
| @@ -102,135 +100,20 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|     {:noreply, socket} |     {:noreply, socket} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do |   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||||
|     new_path = |     {:noreply, socket |> assign(:view_table, !view_table) |> display_containers()} | ||||||
|       if view_table, |  | ||||||
|         do: Routes.container_index_path(Endpoint, :index), |  | ||||||
|         else: Routes.container_index_path(Endpoint, :table) |  | ||||||
|  |  | ||||||
|     {:noreply, socket |> assign(view_table: !view_table) |> push_patch(to: new_path)} |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp display_containers(%{assigns: %{current_user: current_user}} = socket) do |   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||||
|     containers = |     {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))} | ||||||
|       Containers.list_containers(current_user) |> Repo.preload([:tags, :ammo_groups], force: true) |  | ||||||
|  |  | ||||||
|     columns = |  | ||||||
|       [ |  | ||||||
|         %{label: gettext("Name"), key: :name, type: :string}, |  | ||||||
|         %{label: gettext("Description"), key: :desc, type: :string}, |  | ||||||
|         %{label: gettext("Location"), key: :location, type: :string}, |  | ||||||
|         %{label: gettext("Type"), key: :type, type: :string}, |  | ||||||
|         %{label: gettext("Packs"), key: :packs, type: :integer}, |  | ||||||
|         %{label: gettext("Rounds"), key: :rounds, type: :string}, |  | ||||||
|         %{label: gettext("Tags"), key: :tags, type: :tags}, |  | ||||||
|         %{label: nil, key: :actions, sortable: false, type: :actions} |  | ||||||
|       ] |  | ||||||
|       |> Enum.filter(fn %{key: key, type: type} -> |  | ||||||
|         # remove columns if all values match defaults |  | ||||||
|         default_value = |  | ||||||
|           case type do |  | ||||||
|             :boolean -> false |  | ||||||
|             _other_type -> nil |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|         containers |   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||||
|         |> Enum.any?(fn container -> |     {:noreply, | ||||||
|           type in [:tags, :actions] or not (container |> Map.get(key) == default_value) |      socket |> push_patch(to: Routes.container_index_path(Endpoint, :search, search_term))} | ||||||
|         end) |  | ||||||
|       end) |  | ||||||
|  |  | ||||||
|     rows = |  | ||||||
|       containers |  | ||||||
|       |> Enum.map(fn container -> container |> get_row_data_for_container(columns) end) |  | ||||||
|  |  | ||||||
|     socket |  | ||||||
|     |> assign( |  | ||||||
|       containers: containers, |  | ||||||
|       columns: columns, |  | ||||||
|       rows: rows |  | ||||||
|     ) |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec get_row_data_for_container(Container.t(), [map()]) :: [map()] |   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||||
|   defp get_row_data_for_container(container, columns) do |     socket |> assign(:containers, Containers.list_containers(search, current_user)) | ||||||
|     container = container |> Repo.preload([:ammo_groups, :tags]) |  | ||||||
|  |  | ||||||
|     columns |  | ||||||
|     |> Enum.into(%{}, fn %{key: key} -> {key, get_value_for_key(key, container)} end) |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec get_value_for_key(atom(), Container.t()) :: any() |  | ||||||
|   defp get_value_for_key(:packs, container) do |  | ||||||
|     container |> Containers.get_container_ammo_group_count!() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:rounds, container) do |  | ||||||
|     container |> Containers.get_container_rounds!() |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:tags, container) do |  | ||||||
|     assigns = %{container: container} |  | ||||||
|  |  | ||||||
|     {container.tags |> Enum.map(fn %{name: name} -> name end), |  | ||||||
|      ~H""" |  | ||||||
|      <div class="flex flex-wrap justify-center items-center"> |  | ||||||
|        <%= unless @container.tags |> Enum.empty?() do %> |  | ||||||
|          <%= for tag <- @container.tags do %> |  | ||||||
|            <TagCard.simple_tag_card tag={tag} /> |  | ||||||
|          <% end %> |  | ||||||
|        <% end %> |  | ||||||
|  |  | ||||||
|        <div class="mx-4 my-2"> |  | ||||||
|          <.link |  | ||||||
|            patch={Routes.container_index_path(Endpoint, :edit_tags, @container)} |  | ||||||
|            class="text-primary-600 link" |  | ||||||
|          > |  | ||||||
|            <i class="fa-fw fa-lg fas fa-tags"></i> |  | ||||||
|          </.link> |  | ||||||
|        </div> |  | ||||||
|      </div> |  | ||||||
|      """} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(:actions, container) do |  | ||||||
|     assigns = %{container: container} |  | ||||||
|  |  | ||||||
|     ~H""" |  | ||||||
|     <.link |  | ||||||
|       patch={Routes.container_index_path(Endpoint, :edit, @container)} |  | ||||||
|       class="text-primary-600 link" |  | ||||||
|       data-qa={"edit-#{@container.id}"} |  | ||||||
|     > |  | ||||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> |  | ||||||
|     </.link> |  | ||||||
|  |  | ||||||
|     <.link |  | ||||||
|       patch={Routes.container_index_path(Endpoint, :clone, @container)} |  | ||||||
|       class="text-primary-600 link" |  | ||||||
|       data-qa={"clone-#{@container.id}"} |  | ||||||
|     > |  | ||||||
|       <i class="fa-fw fa-lg fas fa-copy"></i> |  | ||||||
|     </.link> |  | ||||||
|  |  | ||||||
|     <.link |  | ||||||
|       href="#" |  | ||||||
|       class="text-primary-600 link" |  | ||||||
|       phx-click="delete" |  | ||||||
|       phx-value-id={@container.id} |  | ||||||
|       data-confirm={ |  | ||||||
|         dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) |  | ||||||
|       } |  | ||||||
|       data-qa={"delete-#{@container.id}"} |  | ||||||
|     > |  | ||||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> |  | ||||||
|     </.link> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp get_value_for_key(key, container), do: container |> Map.get(key) |  | ||||||
|  |  | ||||||
|   def return_to(true = _view_table), do: Routes.container_index_path(Endpoint, :table) |  | ||||||
|   def return_to(false = _view_table), do: Routes.container_index_path(Endpoint, :index) |  | ||||||
| end | end | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
|     <%= gettext("Containers") %> |     <%= gettext("Containers") %> | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <%= if @containers |> Enum.empty?() do %> |   <%= if @containers |> Enum.empty?() and @search |> is_nil() do %> | ||||||
|     <h2 class="title text-xl text-primary-600"> |     <h2 class="title text-xl text-primary-600"> | ||||||
|       <%= gettext("No containers") %> |       <%= gettext("No containers") %> | ||||||
|       <%= display_emoji("😔") %> |       <%= display_emoji("😔") %> | ||||||
| @@ -17,41 +17,65 @@ | |||||||
|       <%= dgettext("actions", "New Container") %> |       <%= dgettext("actions", "New Container") %> | ||||||
|     </.link> |     </.link> | ||||||
|  |  | ||||||
|     <div class="flex flex-col justify-center items-center"> |     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||||
|  |       <.form | ||||||
|  |         :let={f} | ||||||
|  |         for={%{}} | ||||||
|  |         as={:search} | ||||||
|  |         phx-change="search" | ||||||
|  |         phx-submit="search" | ||||||
|  |         class="grow flex items-center" | ||||||
|  |       > | ||||||
|  |         <%= text_input(f, :search_term, | ||||||
|  |           class: "grow input input-primary", | ||||||
|  |           value: @search, | ||||||
|  |           role: "search", | ||||||
|  |           phx_debounce: 300, | ||||||
|  |           placeholder: gettext("Search containers") | ||||||
|  |         ) %> | ||||||
|  |       </.form> | ||||||
|  |  | ||||||
|       <.toggle_button action="toggle_table" value={@view_table}> |       <.toggle_button action="toggle_table" value={@view_table}> | ||||||
|         <span class="title text-lg text-primary-600"> |         <span class="title text-lg text-primary-600"> | ||||||
|           <%= gettext("View as table") %> |           <%= gettext("View as table") %> | ||||||
|         </span> |         </span> | ||||||
|       </.toggle_button> |       </.toggle_button> | ||||||
|     </div> |     </div> | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <div class="max-w-full flex flex-row flex-wrap justify-center items-center"> |     <%= if @containers |> Enum.empty?() do %> | ||||||
|  |       <h2 class="title text-xl text-primary-600"> | ||||||
|  |         <%= gettext("No containers") %> | ||||||
|  |         <%= display_emoji("😔") %> | ||||||
|  |       </h2> | ||||||
|  |     <% else %> | ||||||
|       <%= if @view_table do %> |       <%= if @view_table do %> | ||||||
|         <.live_component |         <.live_component | ||||||
|         module={CanneryWeb.Components.TableComponent} |           module={CanneryWeb.Components.ContainerTableComponent} | ||||||
|           id="containers_index_table" |           id="containers_index_table" | ||||||
|           action={@live_action} |           action={@live_action} | ||||||
|         columns={@columns} |           containers={@containers} | ||||||
|         rows={@rows} |           current_user={@current_user} | ||||||
|       /> |         > | ||||||
|     <% else %> |           <:tag_actions :let={container}> | ||||||
|       <%= for container <- @containers do %> |  | ||||||
|         <.container_card container={container}> |  | ||||||
|           <:tag_actions> |  | ||||||
|             <div class="mx-4 my-2"> |             <div class="mx-4 my-2"> | ||||||
|               <.link |               <.link | ||||||
|                 patch={Routes.container_index_path(Endpoint, :edit_tags, container)} |                 patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||||
|                 class="text-primary-600 link" |                 class="text-primary-600 link" | ||||||
|  |                 aria-label={ | ||||||
|  |                   dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||||
|  |                 } | ||||||
|               > |               > | ||||||
|                 <i class="fa-fw fa-lg fas fa-tags"></i> |                 <i class="fa-fw fa-lg fas fa-tags"></i> | ||||||
|               </.link> |               </.link> | ||||||
|             </div> |             </div> | ||||||
|           </:tag_actions> |           </:tag_actions> | ||||||
|  |           <:actions :let={container}> | ||||||
|             <.link |             <.link | ||||||
|               patch={Routes.container_index_path(Endpoint, :edit, container)} |               patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||||
|               class="text-primary-600 link" |               class="text-primary-600 link" | ||||||
|             data-qa={"edit-#{container.id}"} |               aria-label={ | ||||||
|  |                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||||
|  |               } | ||||||
|             > |             > | ||||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> |               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||||
|             </.link> |             </.link> | ||||||
| @@ -59,7 +83,9 @@ | |||||||
|             <.link |             <.link | ||||||
|               patch={Routes.container_index_path(Endpoint, :clone, container)} |               patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||||
|               class="text-primary-600 link" |               class="text-primary-600 link" | ||||||
|             data-qa={"clone-#{container.id}"} |               aria-label={ | ||||||
|  |                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||||
|  |               } | ||||||
|             > |             > | ||||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> |               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||||
|             </.link> |             </.link> | ||||||
| @@ -70,41 +96,105 @@ | |||||||
|               phx-click="delete" |               phx-click="delete" | ||||||
|               phx-value-id={container.id} |               phx-value-id={container.id} | ||||||
|               data-confirm={ |               data-confirm={ | ||||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) |                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||||
|  |                   name: container.name | ||||||
|  |                 ) | ||||||
|  |               } | ||||||
|  |               aria-label={ | ||||||
|  |                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||||
|  |               } | ||||||
|  |             > | ||||||
|  |               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||||
|  |             </.link> | ||||||
|  |           </:actions> | ||||||
|  |         </.live_component> | ||||||
|  |       <% else %> | ||||||
|  |         <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||||
|  |           <.container_card | ||||||
|  |             :for={container <- @containers} | ||||||
|  |             container={container} | ||||||
|  |             current_user={@current_user} | ||||||
|  |           > | ||||||
|  |             <:tag_actions> | ||||||
|  |               <div class="mx-4 my-2"> | ||||||
|  |                 <.link | ||||||
|  |                   patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||||
|  |                   class="text-primary-600 link" | ||||||
|  |                   aria-label={ | ||||||
|  |                     dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||||
|  |                   } | ||||||
|  |                 > | ||||||
|  |                   <i class="fa-fw fa-lg fas fa-tags"></i> | ||||||
|  |                 </.link> | ||||||
|  |               </div> | ||||||
|  |             </:tag_actions> | ||||||
|  |             <.link | ||||||
|  |               patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||||
|  |               class="text-primary-600 link" | ||||||
|  |               aria-label={ | ||||||
|  |                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||||
|  |               } | ||||||
|  |             > | ||||||
|  |               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||||
|  |             </.link> | ||||||
|  |  | ||||||
|  |             <.link | ||||||
|  |               patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||||
|  |               class="text-primary-600 link" | ||||||
|  |               aria-label={ | ||||||
|  |                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||||
|  |               } | ||||||
|  |             > | ||||||
|  |               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||||
|  |             </.link> | ||||||
|  |  | ||||||
|  |             <.link | ||||||
|  |               href="#" | ||||||
|  |               class="text-primary-600 link" | ||||||
|  |               phx-click="delete" | ||||||
|  |               phx-value-id={container.id} | ||||||
|  |               data-confirm={ | ||||||
|  |                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||||
|  |                   name: container.name | ||||||
|  |                 ) | ||||||
|  |               } | ||||||
|  |               aria-label={ | ||||||
|  |                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||||
|               } |               } | ||||||
|             data-qa={"delete-#{container.id}"} |  | ||||||
|             > |             > | ||||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> |               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||||
|             </.link> |             </.link> | ||||||
|           </.container_card> |           </.container_card> | ||||||
|       <% end %> |  | ||||||
|     <% end %> |  | ||||||
|         </div> |         </div> | ||||||
|  |       <% end %> | ||||||
|  |     <% end %> | ||||||
|  |   <% end %> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <%= if @live_action in [:new, :edit, :clone] do %> | <%= case @live_action do %> | ||||||
|   <.modal return_to={return_to(@view_table)}> |   <% modifying when modifying in [:new, :edit, :clone] -> %> | ||||||
|  |     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||||
|       <.live_component |       <.live_component | ||||||
|         module={CanneryWeb.ContainerLive.FormComponent} |         module={CanneryWeb.ContainerLive.FormComponent} | ||||||
|         id={@container.id || :new} |         id={@container.id || :new} | ||||||
|         title={@page_title} |         title={@page_title} | ||||||
|         action={@live_action} |         action={@live_action} | ||||||
|         container={@container} |         container={@container} | ||||||
|       return_to={return_to(@view_table)} |         return_to={Routes.container_index_path(Endpoint, :index)} | ||||||
|         current_user={@current_user} |         current_user={@current_user} | ||||||
|       /> |       /> | ||||||
|     </.modal> |     </.modal> | ||||||
| <% end %> |   <% :edit_tags -> %> | ||||||
|  |     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||||
| <%= if @live_action == :edit_tags do %> |  | ||||||
|   <.modal return_to={return_to(@view_table)}> |  | ||||||
|       <.live_component |       <.live_component | ||||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} |         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||||
|         id={@container.id} |         id={@container.id} | ||||||
|         title={@page_title} |         title={@page_title} | ||||||
|         action={@live_action} |         action={@live_action} | ||||||
|         container={@container} |         container={@container} | ||||||
|  |         current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)} | ||||||
|         current_user={@current_user} |         current_user={@current_user} | ||||||
|       /> |       /> | ||||||
|     </.modal> |     </.modal> | ||||||
|  |   <% _ -> %> | ||||||
| <% end %> | <% end %> | ||||||
|   | |||||||
| @@ -4,22 +4,23 @@ defmodule CanneryWeb.ContainerLive.Show do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_view |   use CanneryWeb, :live_view | ||||||
|   import CanneryWeb.Components.{AmmoGroupCard, TagCard} |   alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container} | ||||||
|   alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container, Repo, Tags} |  | ||||||
|   alias CanneryWeb.Endpoint |   alias CanneryWeb.Endpoint | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def mount(_params, _session, socket), do: {:ok, socket |> assign(show_used: false)} |   def mount(_params, _session, socket), | ||||||
|  |     do: {:ok, socket |> assign(class: :all, view_table: true)} | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def handle_params( |   def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||||
|         %{"id" => id}, |     socket = | ||||||
|         _session, |       socket | ||||||
|         %{assigns: %{current_user: current_user}} = socket |       |> assign(:view_table, true) | ||||||
|       ) do |       |> render_container(id, current_user) | ||||||
|     {:noreply, socket |> render_container(id, current_user)} |  | ||||||
|  |     {:noreply, socket} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
| @@ -29,7 +30,7 @@ defmodule CanneryWeb.ContainerLive.Show do | |||||||
|         %{assigns: %{container: container, current_user: current_user}} = socket |         %{assigns: %{container: container, current_user: current_user}} = socket | ||||||
|       ) do |       ) do | ||||||
|     socket = |     socket = | ||||||
|       case Tags.get_tag(tag_id, current_user) do |       case Containers.get_tag(tag_id, current_user) do | ||||||
|         {:ok, tag} -> |         {:ok, tag} -> | ||||||
|           _count = Containers.remove_tag!(container, tag, current_user) |           _count = Containers.remove_tag!(container, tag, current_user) | ||||||
|  |  | ||||||
| @@ -41,14 +42,13 @@ defmodule CanneryWeb.ContainerLive.Show do | |||||||
|  |  | ||||||
|           socket |> put_flash(:info, prompt) |> render_container() |           socket |> put_flash(:info, prompt) |> render_container() | ||||||
|  |  | ||||||
|         {:error, error_string} -> |         {:error, :not_found} -> | ||||||
|           socket |> put_flash(:error, error_string) |           socket |> put_flash(:error, dgettext("errors", "Tag not found")) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     {:noreply, socket} |     {:noreply, socket} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event( |   def handle_event( | ||||||
|         "delete_container", |         "delete_container", | ||||||
|         _params, |         _params, | ||||||
| @@ -64,13 +64,13 @@ defmodule CanneryWeb.ContainerLive.Show do | |||||||
|           |> put_flash(:info, prompt) |           |> put_flash(:info, prompt) | ||||||
|           |> push_navigate(to: Routes.container_index_path(socket, :index)) |           |> push_navigate(to: Routes.container_index_path(socket, :index)) | ||||||
|  |  | ||||||
|         {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} -> |         {:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> | ||||||
|           ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ") |           packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") | ||||||
|  |  | ||||||
|           prompt = |           prompt = | ||||||
|             dgettext("errors", "Could not delete %{name}: %{error}", |             dgettext("errors", "Could not delete %{name}: %{error}", | ||||||
|               name: changeset |> Changeset.get_field(:name, "container"), |               name: changeset |> Changeset.get_field(:name, "container"), | ||||||
|               error: ammo_groups_error |               error: packs_error | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|           socket |> put_flash(:error, prompt) |           socket |> put_flash(:error, prompt) | ||||||
| @@ -82,32 +82,56 @@ defmodule CanneryWeb.ContainerLive.Show do | |||||||
|     {:noreply, socket} |     {:noreply, socket} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @impl true |   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do |     {:noreply, socket |> assign(:view_table, !view_table) |> render_container()} | ||||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> render_container()} |   end | ||||||
|  |  | ||||||
|  |   def handle_event("change_class", %{"type" => %{"class" => "rifle"}}, socket) do | ||||||
|  |     {:noreply, socket |> assign(:class, :rifle) |> render_container()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("change_class", %{"type" => %{"class" => "shotgun"}}, socket) do | ||||||
|  |     {:noreply, socket |> assign(:class, :shotgun) |> render_container()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("change_class", %{"type" => %{"class" => "pistol"}}, socket) do | ||||||
|  |     {:noreply, socket |> assign(:class, :pistol) |> render_container()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("change_class", %{"type" => %{"class" => _all}}, socket) do | ||||||
|  |     {:noreply, socket |> assign(:class, :all) |> render_container()} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() |   @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() | ||||||
|   defp render_container( |   defp render_container( | ||||||
|          %{assigns: %{live_action: live_action, show_used: show_used}} = socket, |          %{assigns: %{class: class, live_action: live_action}} = socket, | ||||||
|          id, |          id, | ||||||
|          current_user |          current_user | ||||||
|        ) do |        ) do | ||||||
|     %{name: container_name} = |     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||||
|       container = |     packs = Ammo.list_packs_for_container(container, class, current_user) | ||||||
|       Containers.get_container!(id, current_user) |     original_counts = packs |> Ammo.get_original_counts(current_user) | ||||||
|       |> Repo.preload([:tags], force: true) |     cprs = packs |> Ammo.get_cprs(current_user) | ||||||
|  |     last_used_dates = packs |> ActivityLog.get_last_used_dates(current_user) | ||||||
|     ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used) |  | ||||||
|  |  | ||||||
|     page_title = |     page_title = | ||||||
|       case live_action do |       case live_action do | ||||||
|         :show -> gettext("Show %{name}", name: container_name) |         :show -> container_name | ||||||
|         :edit -> gettext("Edit %{name}", name: container_name) |         :edit -> gettext("Edit %{name}", name: container_name) | ||||||
|         :edit_tags -> gettext("Edit %{name} tags", name: container_name) |         :edit_tags -> gettext("Edit %{name} tags", name: container_name) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|     socket |> assign(container: container, ammo_groups: ammo_groups, page_title: page_title) |     socket | ||||||
|  |     |> assign( | ||||||
|  |       container: container, | ||||||
|  |       round_count: Ammo.get_round_count_for_container!(container, current_user), | ||||||
|  |       packs_count: Ammo.get_packs_count_for_container!(container, current_user), | ||||||
|  |       packs: packs, | ||||||
|  |       original_counts: original_counts, | ||||||
|  |       cprs: cprs, | ||||||
|  |       last_used_dates: last_used_dates, | ||||||
|  |       page_title: page_title | ||||||
|  |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec render_container(Socket.t()) :: Socket.t() |   @spec render_container(Socket.t()) :: Socket.t() | ||||||
|   | |||||||
| @@ -3,42 +3,36 @@ | |||||||
|     <%= @container.name %> |     <%= @container.name %> | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <%= if @container.desc do %> |   <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||||
|     <span class="rounded-lg title text-lg"> |  | ||||||
|     <%= gettext("Description:") %> |     <%= gettext("Description:") %> | ||||||
|     <%= @container.desc %> |     <%= @container.desc %> | ||||||
|   </span> |   </span> | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <span class="rounded-lg title text-lg"> |   <span class="rounded-lg title text-lg"> | ||||||
|     <%= gettext("Type:") %> |     <%= gettext("Type:") %> | ||||||
|     <%= @container.type %> |     <%= @container.type %> | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <%= if @container.location do %> |   <span :if={@container.location} class="rounded-lg title text-lg"> | ||||||
|     <span class="rounded-lg title text-lg"> |  | ||||||
|     <%= gettext("Location:") %> |     <%= gettext("Location:") %> | ||||||
|     <%= @container.location %> |     <%= @container.location %> | ||||||
|   </span> |   </span> | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> |  | ||||||
|   <span class="rounded-lg title text-lg"> |   <span class="rounded-lg title text-lg"> | ||||||
|     <%= gettext("Packs:") %> |     <%= gettext("Packs:") %> | ||||||
|       <%= Enum.count(@ammo_groups) %> |     <%= @packs_count %> | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <span class="rounded-lg title text-lg"> |   <span class="rounded-lg title text-lg"> | ||||||
|     <%= gettext("Rounds:") %> |     <%= gettext("Rounds:") %> | ||||||
|       <%= @container |> Containers.get_container_rounds!() %> |     <%= @round_count %> | ||||||
|   </span> |   </span> | ||||||
|   <% end %> |  | ||||||
|  |  | ||||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> |   <div class="flex space-x-4 justify-center items-center text-primary-600"> | ||||||
|     <.link |     <.link | ||||||
|       patch={Routes.container_show_path(Endpoint, :edit, @container)} |       patch={Routes.container_show_path(Endpoint, :edit, @container)} | ||||||
|       class="text-primary-600 link" |       class="text-primary-600 link" | ||||||
|       data-qa="edit" |       aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)} | ||||||
|     > |     > | ||||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> |       <i class="fa-fw fa-lg fas fa-edit"></i> | ||||||
|     </.link> |     </.link> | ||||||
| @@ -50,7 +44,9 @@ | |||||||
|       data-confirm={ |       data-confirm={ | ||||||
|         dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) |         dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) | ||||||
|       } |       } | ||||||
|       data-qa="delete" |       aria-label={ | ||||||
|  |         dgettext("actions", "Delete %{container_name}", container_name: @container.name) | ||||||
|  |       } | ||||||
|     > |     > | ||||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> |       <i class="fa-fw fa-lg fas fa-trash"></i> | ||||||
|     </.link> |     </.link> | ||||||
| @@ -74,9 +70,7 @@ | |||||||
|     </div> |     </div> | ||||||
|   <% else %> |   <% else %> | ||||||
|     <div class="flex flex-wrap justify-center items-center"> |     <div class="flex flex-wrap justify-center items-center"> | ||||||
|       <%= for tag <- @container.tags do %> |       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||||
|         <.simple_tag_card tag={tag} /> |  | ||||||
|       <% end %> |  | ||||||
|  |  | ||||||
|       <div class="mx-4 my-2"> |       <div class="mx-4 my-2"> | ||||||
|         <.link |         <.link | ||||||
| @@ -91,30 +85,76 @@ | |||||||
|  |  | ||||||
|   <hr class="mb-4 hr" /> |   <hr class="mb-4 hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-col justify-center items-center"> |   <div class="flex justify-center items-center space-x-4"> | ||||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> |     <.form | ||||||
|  |       :let={f} | ||||||
|  |       for={%{}} | ||||||
|  |       as={:type} | ||||||
|  |       phx-change="change_class" | ||||||
|  |       phx-submit="change_class" | ||||||
|  |       class="flex items-center" | ||||||
|  |     > | ||||||
|  |       <%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> | ||||||
|  |  | ||||||
|  |       <%= select( | ||||||
|  |         f, | ||||||
|  |         :class, | ||||||
|  |         [ | ||||||
|  |           {gettext("All"), :all}, | ||||||
|  |           {gettext("Rifle"), :rifle}, | ||||||
|  |           {gettext("Shotgun"), :shotgun}, | ||||||
|  |           {gettext("Pistol"), :pistol} | ||||||
|  |         ], | ||||||
|  |         class: "mx-2 my-1 min-w-md input input-primary", | ||||||
|  |         value: @class | ||||||
|  |       ) %> | ||||||
|  |     </.form> | ||||||
|  |  | ||||||
|  |     <.toggle_button action="toggle_table" value={@view_table}> | ||||||
|       <span class="title text-lg text-primary-600"> |       <span class="title text-lg text-primary-600"> | ||||||
|         <%= dgettext("actions", "Show used") %> |         <%= gettext("View as table") %> | ||||||
|       </span> |       </span> | ||||||
|     </.toggle_button> |     </.toggle_button> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div> |   <div class="w-full p-4"> | ||||||
|     <%= if @ammo_groups |> Enum.empty?() do %> |     <%= if @packs |> Enum.empty?() do %> | ||||||
|       <h2 class="mx-8 my-4 title text-lg text-primary-600"> |       <h2 class="mx-4 title text-lg text-primary-600 text-center"> | ||||||
|         <%= gettext("No ammo in this container") %> |         <%= gettext("No ammo in this container") %> | ||||||
|       </h2> |       </h2> | ||||||
|     <% else %> |     <% else %> | ||||||
|       <div class="flex flex-wrap justify-center items-center"> |       <%= if @view_table do %> | ||||||
|         <%= for ammo_group <- @ammo_groups do %> |         <.live_component | ||||||
|           <.ammo_group_card ammo_group={ammo_group} /> |           module={CanneryWeb.Components.PackTableComponent} | ||||||
|         <% end %> |           id="type-show-table" | ||||||
|  |           packs={@packs} | ||||||
|  |           current_user={@current_user} | ||||||
|  |           show_used={false} | ||||||
|  |         > | ||||||
|  |           <:type :let={%{name: type_name} = type}> | ||||||
|  |             <.link navigate={Routes.type_show_path(Endpoint, :show, type)} class="link"> | ||||||
|  |               <%= type_name %> | ||||||
|  |             </.link> | ||||||
|  |           </:type> | ||||||
|  |         </.live_component> | ||||||
|  |       <% else %> | ||||||
|  |         <div class="flex flex-wrap justify-center items-stretch"> | ||||||
|  |           <.pack_card | ||||||
|  |             :for={%{id: pack_id} = pack <- @packs} | ||||||
|  |             pack={pack} | ||||||
|  |             original_count={Map.fetch!(@original_counts, pack_id)} | ||||||
|  |             cpr={Map.get(@cprs, pack_id)} | ||||||
|  |             last_used_date={Map.get(@last_used_dates, pack_id)} | ||||||
|  |             current_user={@current_user} | ||||||
|  |           /> | ||||||
|         </div> |         </div> | ||||||
|       <% end %> |       <% end %> | ||||||
|  |     <% end %> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <%= if @live_action in [:edit] do %> | <%= case @live_action do %> | ||||||
|  |   <% :edit -> %> | ||||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> |     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||||
|       <.live_component |       <.live_component | ||||||
|         module={CanneryWeb.ContainerLive.FormComponent} |         module={CanneryWeb.ContainerLive.FormComponent} | ||||||
| @@ -126,9 +166,7 @@ | |||||||
|         current_user={@current_user} |         current_user={@current_user} | ||||||
|       /> |       /> | ||||||
|     </.modal> |     </.modal> | ||||||
| <% end %> |   <% :edit_tags -> %> | ||||||
|  |  | ||||||
| <%= if @live_action == :edit_tags do %> |  | ||||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> |     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||||
|       <.live_component |       <.live_component | ||||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} |         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||||
| @@ -137,7 +175,9 @@ | |||||||
|         action={@live_action} |         action={@live_action} | ||||||
|         container={@container} |         container={@container} | ||||||
|         return_to={Routes.container_show_path(Endpoint, :show, @container)} |         return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||||
|  |         current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||||
|         current_user={@current_user} |         current_user={@current_user} | ||||||
|       /> |       /> | ||||||
|     </.modal> |     </.modal> | ||||||
|  |   <% _ -> %> | ||||||
| <% end %> | <% end %> | ||||||
|   | |||||||
| @@ -7,197 +7,11 @@ defmodule CanneryWeb.HomeLive do | |||||||
|   alias Cannery.Accounts |   alias Cannery.Accounts | ||||||
|   alias CanneryWeb.Endpoint |   alias CanneryWeb.Endpoint | ||||||
|  |  | ||||||
|  |   @version Mix.Project.config()[:version] | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def mount(_params, _session, socket) do |   def mount(_params, _session, socket) do | ||||||
|     admins = Accounts.list_users_by_role(:admin) |     admins = Accounts.list_users_by_role(:admin) | ||||||
|     socket = socket |> assign(page_title: "Home", query: "", results: %{}, admins: admins) |     {:ok, socket |> assign(page_title: gettext("Home"), admins: admins, version: @version)} | ||||||
|     {:ok, socket} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("suggest", %{"q" => query}, socket) do |  | ||||||
|     {:noreply, socket |> assign(results: search(query), query: query)} |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def handle_event("search", %{"q" => query}, socket) do |  | ||||||
|     case search(query) do |  | ||||||
|       %{^query => vsn} -> |  | ||||||
|         {:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")} |  | ||||||
|  |  | ||||||
|       _no_query -> |  | ||||||
|         {:noreply, |  | ||||||
|          socket |  | ||||||
|          |> put_flash(:error, "No dependencies found matching \"#{query}\"") |  | ||||||
|          |> assign(results: %{}, query: query)} |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   @impl true |  | ||||||
|   def render(assigns) do |  | ||||||
|     ~H""" |  | ||||||
|     <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> |  | ||||||
|       <img |  | ||||||
|         src={Routes.static_path(Endpoint, "/images/cannery.svg")} |  | ||||||
|         alt={gettext("Cannery logo")} |  | ||||||
|         class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" |  | ||||||
|         title={gettext("isn't he cute >:3")} |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|       <h1 class="title text-primary-600 text-2xl"> |  | ||||||
|         <%= gettext("Welcome to %{name}", name: "Cannery") %> |  | ||||||
|       </h1> |  | ||||||
|  |  | ||||||
|       <h2 class="title text-primary-600 text-lg"> |  | ||||||
|         <%= gettext("The self-hosted firearm tracker website") %> |  | ||||||
|       </h2> |  | ||||||
|  |  | ||||||
|       <hr class="hr" /> |  | ||||||
|  |  | ||||||
|       <ul class="flex flex-col space-y-4 text-center"> |  | ||||||
|         <li class="flex flex-col justify-center items-center |  | ||||||
|           space-y-2"> |  | ||||||
|           <b class="whitespace-nowrap"> |  | ||||||
|             <%= gettext("Easy to Use:") %> |  | ||||||
|           </b> |  | ||||||
|           <p> |  | ||||||
|             <%= gettext( |  | ||||||
|               "%{name} lets you easily keep an eye on your ammo levels before and after range day", |  | ||||||
|               name: "Cannery" |  | ||||||
|             ) %> |  | ||||||
|           </p> |  | ||||||
|         </li> |  | ||||||
|         <li class="flex flex-col justify-center items-center |  | ||||||
|           space-y-2"> |  | ||||||
|           <b class="whitespace-nowrap"> |  | ||||||
|             <%= gettext("Secure:") %> |  | ||||||
|           </b> |  | ||||||
|           <p> |  | ||||||
|             <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %> |  | ||||||
|             <%= gettext("Your data stays with you, period") %> |  | ||||||
|           </p> |  | ||||||
|         </li> |  | ||||||
|         <li class="flex flex-col justify-center items-center |  | ||||||
|           space-y-2"> |  | ||||||
|           <b class="whitespace-nowrap"> |  | ||||||
|             <%= gettext("Simple:") %> |  | ||||||
|           </b> |  | ||||||
|           <p> |  | ||||||
|             <%= gettext("Access from any internet-capable device") %> |  | ||||||
|           </p> |  | ||||||
|         </li> |  | ||||||
|       </ul> |  | ||||||
|  |  | ||||||
|       <hr class="hr" /> |  | ||||||
|  |  | ||||||
|       <ul class="flex flex-col space-y-2 text-center justify-center"> |  | ||||||
|         <h2 class="title text-primary-600 text-lg"> |  | ||||||
|           <%= gettext("Instance Information") %> |  | ||||||
|         </h2> |  | ||||||
|  |  | ||||||
|         <li class="flex flex-col justify-center space-x-2"> |  | ||||||
|           <b> |  | ||||||
|             <%= gettext("Admins:") %> |  | ||||||
|           </b> |  | ||||||
|           <p> |  | ||||||
|             <%= if @admins |> Enum.empty?() do %> |  | ||||||
|               <.link |  | ||||||
|                 href={Routes.user_registration_path(CanneryWeb.Endpoint, :new)} |  | ||||||
|                 class="hover:underline" |  | ||||||
|               > |  | ||||||
|                 <%= dgettext("prompts", "Register to setup %{name}", name: "Cannery") %> |  | ||||||
|               </.link> |  | ||||||
|             <% else %> |  | ||||||
|               <div class="flex flex-wrap justify-center space-x-2"> |  | ||||||
|                 <%= for admin <- @admins do %> |  | ||||||
|                   <a class="hover:underline" href={"mailto:#{admin.email}"}> |  | ||||||
|                     <%= admin.email %> |  | ||||||
|                   </a> |  | ||||||
|                 <% end %> |  | ||||||
|               </div> |  | ||||||
|             <% end %> |  | ||||||
|           </p> |  | ||||||
|         </li> |  | ||||||
|  |  | ||||||
|         <li class="flex flex-row justify-center space-x-2"> |  | ||||||
|           <b>Registration:</b> |  | ||||||
|           <p> |  | ||||||
|             <%= Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] |  | ||||||
|             |> case do |  | ||||||
|               "public" -> gettext("Public Signups") |  | ||||||
|               _ -> gettext("Invite Only") |  | ||||||
|             end %> |  | ||||||
|           </p> |  | ||||||
|         </li> |  | ||||||
|  |  | ||||||
|         <li class="flex flex-row justify-center items-center space-x-2"> |  | ||||||
|           <b>Version:</b> |  | ||||||
|           <.link |  | ||||||
|             href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md" |  | ||||||
|             class="flex flex-row justify-center items-center space-x-2 hover:underline" |  | ||||||
|             target="_blank" |  | ||||||
|             rel="noopener noreferrer" |  | ||||||
|           > |  | ||||||
|             <p>0.6.0</p> |  | ||||||
|             <i class="fas fa-md fa-info-circle"></i> |  | ||||||
|           </.link> |  | ||||||
|         </li> |  | ||||||
|       </ul> |  | ||||||
|  |  | ||||||
|       <hr class="hr" /> |  | ||||||
|  |  | ||||||
|       <ul class="flex flex-col space-y-2 text-center justify-center"> |  | ||||||
|         <h2 class="title text-primary-600 text-lg"> |  | ||||||
|           <%= gettext("Get involved!") %> |  | ||||||
|         </h2> |  | ||||||
|  |  | ||||||
|         <li class="flex flex-col justify-center space-x-2"> |  | ||||||
|           <.link |  | ||||||
|             class="flex flex-row justify-center items-center space-x-2 hover:underline" |  | ||||||
|             href="https://gitea.bubbletea.dev/shibao/cannery" |  | ||||||
|             target="_blank" |  | ||||||
|             rel="noopener noreferrer" |  | ||||||
|           > |  | ||||||
|             <p><%= gettext("View the source code") %></p> |  | ||||||
|             <i class="fas fa-md fa-code"></i> |  | ||||||
|           </.link> |  | ||||||
|         </li> |  | ||||||
|         <li class="flex flex-col justify-center space-x-2"> |  | ||||||
|           <.link |  | ||||||
|             class="flex flex-row justify-center items-center space-x-2 hover:underline" |  | ||||||
|             href="https://weblate.bubbletea.dev/engage/cannery" |  | ||||||
|             target="_blank" |  | ||||||
|             rel="noopener noreferrer" |  | ||||||
|           > |  | ||||||
|             <p><%= gettext("Help translate") %></p> |  | ||||||
|             <i class="fas fa-md fa-language"></i> |  | ||||||
|           </.link> |  | ||||||
|         </li> |  | ||||||
|         <li class="flex flex-col justify-center space-x-2"> |  | ||||||
|           <.link |  | ||||||
|             class="flex flex-row justify-center items-center space-x-2 hover:underline" |  | ||||||
|             href="https://gitea.bubbletea.dev/shibao/cannery/issues/new" |  | ||||||
|             target="_blank" |  | ||||||
|             rel="noopener noreferrer" |  | ||||||
|           > |  | ||||||
|             <p><%= gettext("Report bugs or request features") %></p> |  | ||||||
|             <i class="fas fa-md fa-spider"></i> |  | ||||||
|           </.link> |  | ||||||
|         </li> |  | ||||||
|       </ul> |  | ||||||
|     </div> |  | ||||||
|     """ |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   defp search(query) do |  | ||||||
|     if not CanneryWeb.Endpoint.config(:code_reloader) do |  | ||||||
|       raise "action disabled when not in development" |  | ||||||
|     end |  | ||||||
|  |  | ||||||
|     for {app, desc, vsn} <- Application.started_applications(), |  | ||||||
|         app = to_string(app), |  | ||||||
|         String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"), |  | ||||||
|         into: %{}, |  | ||||||
|         do: {app, vsn} |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										146
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | |||||||
|  | <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> | ||||||
|  |   <img | ||||||
|  |     src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||||
|  |     alt={gettext("Cannery logo")} | ||||||
|  |     class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" | ||||||
|  |     title={gettext("isn't he cute >:3")} | ||||||
|  |   /> | ||||||
|  |  | ||||||
|  |   <h1 class="title text-primary-600 text-2xl"> | ||||||
|  |     <%= gettext("Welcome to Cannery") %> | ||||||
|  |   </h1> | ||||||
|  |  | ||||||
|  |   <h2 class="title text-primary-600 text-lg"> | ||||||
|  |     <%= gettext("The self-hosted firearm tracker website") %> | ||||||
|  |   </h2> | ||||||
|  |  | ||||||
|  |   <hr class="hr" /> | ||||||
|  |  | ||||||
|  |   <ul class="flex flex-col space-y-4 text-center"> | ||||||
|  |     <li class="flex flex-col justify-center items-center space-y-2"> | ||||||
|  |       <b class="whitespace-nowrap"> | ||||||
|  |         <%= gettext("Easy to Use:") %> | ||||||
|  |       </b> | ||||||
|  |       <p> | ||||||
|  |         <%= gettext( | ||||||
|  |           "Cannery lets you easily keep an eye on your ammo levels before and after range day" | ||||||
|  |         ) %> | ||||||
|  |       </p> | ||||||
|  |     </li> | ||||||
|  |     <li class="flex flex-col justify-center items-center space-y-2"> | ||||||
|  |       <b class="whitespace-nowrap"> | ||||||
|  |         <%= gettext("Secure:") %> | ||||||
|  |       </b> | ||||||
|  |       <p> | ||||||
|  |         <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %> | ||||||
|  |         <%= gettext("Your data stays with you, period") %> | ||||||
|  |       </p> | ||||||
|  |     </li> | ||||||
|  |     <li class="flex flex-col justify-center items-center space-y-2"> | ||||||
|  |       <b class="whitespace-nowrap"> | ||||||
|  |         <%= gettext("Simple:") %> | ||||||
|  |       </b> | ||||||
|  |       <p> | ||||||
|  |         <%= gettext("Access from any internet-capable device") %> | ||||||
|  |       </p> | ||||||
|  |     </li> | ||||||
|  |   </ul> | ||||||
|  |  | ||||||
|  |   <hr class="hr" /> | ||||||
|  |  | ||||||
|  |   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||||
|  |     <h2 class="title text-primary-600 text-lg"> | ||||||
|  |       <%= gettext("Instance Information") %> | ||||||
|  |     </h2> | ||||||
|  |  | ||||||
|  |     <li class="flex flex-col justify-center space-x-2"> | ||||||
|  |       <b> | ||||||
|  |         <%= gettext("Admins:") %> | ||||||
|  |       </b> | ||||||
|  |       <p> | ||||||
|  |         <%= if @admins |> Enum.empty?() do %> | ||||||
|  |           <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline"> | ||||||
|  |             <%= dgettext("prompts", "Register to setup Cannery") %> | ||||||
|  |           </.link> | ||||||
|  |         <% else %> | ||||||
|  |           <div class="flex flex-wrap justify-center space-x-2"> | ||||||
|  |             <.link | ||||||
|  |               :for={%{email: email} <- @admins} | ||||||
|  |               class="hover:underline" | ||||||
|  |               href={"mailto:#{email}"} | ||||||
|  |             > | ||||||
|  |               <%= email %> | ||||||
|  |             </.link> | ||||||
|  |           </div> | ||||||
|  |         <% end %> | ||||||
|  |       </p> | ||||||
|  |     </li> | ||||||
|  |  | ||||||
|  |     <li class="flex flex-row justify-center space-x-2"> | ||||||
|  |       <b><%= gettext("Registration:") %></b> | ||||||
|  |       <p> | ||||||
|  |         <%= case Accounts.registration_mode() do | ||||||
|  |           :public -> gettext("Public Signups") | ||||||
|  |           :invite_only -> gettext("Invite Only") | ||||||
|  |         end %> | ||||||
|  |       </p> | ||||||
|  |     </li> | ||||||
|  |  | ||||||
|  |     <li class="flex flex-row justify-center items-center space-x-2"> | ||||||
|  |       <b><%= gettext("Version:") %></b> | ||||||
|  |       <.link | ||||||
|  |         href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md" | ||||||
|  |         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||||
|  |         target="_blank" | ||||||
|  |         rel="noopener noreferrer" | ||||||
|  |       > | ||||||
|  |         <p> | ||||||
|  |           <%= @version %> | ||||||
|  |         </p> | ||||||
|  |         <i class="fas fa-md fa-info-circle"></i> | ||||||
|  |       </.link> | ||||||
|  |     </li> | ||||||
|  |   </ul> | ||||||
|  |  | ||||||
|  |   <hr class="hr" /> | ||||||
|  |  | ||||||
|  |   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||||
|  |     <h2 class="title text-primary-600 text-lg"> | ||||||
|  |       <%= gettext("Get involved!") %> | ||||||
|  |     </h2> | ||||||
|  |  | ||||||
|  |     <li class="flex flex-col justify-center space-x-2"> | ||||||
|  |       <.link | ||||||
|  |         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||||
|  |         href="https://gitea.bubbletea.dev/shibao/cannery" | ||||||
|  |         target="_blank" | ||||||
|  |         rel="noopener noreferrer" | ||||||
|  |       > | ||||||
|  |         <p><%= gettext("View the source code") %></p> | ||||||
|  |         <i class="fas fa-md fa-code"></i> | ||||||
|  |       </.link> | ||||||
|  |     </li> | ||||||
|  |     <li class="flex flex-col justify-center space-x-2"> | ||||||
|  |       <.link | ||||||
|  |         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||||
|  |         href="https://weblate.bubbletea.dev/engage/cannery" | ||||||
|  |         target="_blank" | ||||||
|  |         rel="noopener noreferrer" | ||||||
|  |       > | ||||||
|  |         <p><%= gettext("Help translate") %></p> | ||||||
|  |         <i class="fas fa-md fa-language"></i> | ||||||
|  |       </.link> | ||||||
|  |     </li> | ||||||
|  |     <li class="flex flex-col justify-center space-x-2"> | ||||||
|  |       <.link | ||||||
|  |         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||||
|  |         href="https://gitea.bubbletea.dev/shibao/cannery/issues/new" | ||||||
|  |         target="_blank" | ||||||
|  |         rel="noopener noreferrer" | ||||||
|  |       > | ||||||
|  |         <p><%= gettext("Report bugs or request features") %></p> | ||||||
|  |         <i class="fas fa-md fa-spider"></i> | ||||||
|  |       </.link> | ||||||
|  |     </li> | ||||||
|  |   </ul> | ||||||
|  | </div> | ||||||
| @@ -1,11 +1,11 @@ | |||||||
| defmodule CanneryWeb.InviteLive.FormComponent do | defmodule CanneryWeb.InviteLive.FormComponent do | ||||||
|   @moduledoc """ |   @moduledoc """ | ||||||
|   Livecomponent that can update or create an Cannery.Invites.Invite |   Livecomponent that can update or create an Cannery.Accounts.Invite | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |   use CanneryWeb, :live_component | ||||||
|   alias Cannery.{Accounts.User, Invites, Invites.Invite} |  | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|  |   alias Cannery.Accounts.{Invite, Invites, User} | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
| @@ -38,7 +38,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do | |||||||
|  |  | ||||||
|     changeset = |     changeset = | ||||||
|       case action do |       case action do | ||||||
|         :new -> invite |> Invite.create_changeset(user, "example_token", invite_params) |         :new -> Invite.create_changeset(user, "example_token", invite_params) | ||||||
|         :edit -> invite |> Invite.update_changeset(invite_params) |         :edit -> invite |> Invite.update_changeset(invite_params) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,13 +11,17 @@ | |||||||
|     phx-change="validate" |     phx-change="validate" | ||||||
|     phx-submit="save" |     phx-submit="save" | ||||||
|   > |   > | ||||||
|     <%= if @changeset.action && not @changeset.valid? do %> |     <div | ||||||
|       <div class="invalid-feedback col-span-3 text-center"> |       :if={@changeset.action && not @changeset.valid?()} | ||||||
|  |       class="invalid-feedback col-span-3 text-center" | ||||||
|  |     > | ||||||
|       <%= changeset_errors(@changeset) %> |       <%= changeset_errors(@changeset) %> | ||||||
|     </div> |     </div> | ||||||
|     <% end %> |  | ||||||
|  |  | ||||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> |     <%= label(f, :name, gettext("Name"), | ||||||
|  |       class: "title text-lg text-primary-600", | ||||||
|  |       maxlength: 255 | ||||||
|  |     ) %> | ||||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> |     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||||
|     <%= error_tag(f, :name, "col-span-3") %> |     <%= error_tag(f, :name, "col-span-3") %> | ||||||
|  |  | ||||||
| @@ -25,7 +29,7 @@ | |||||||
|     <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> |     <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> | ||||||
|     <%= error_tag(f, :uses_left, "col-span-3") %> |     <%= error_tag(f, :uses_left, "col-span-3") %> | ||||||
|     <span class="col-span-3 text-primary-400 italic text-center"> |     <span class="col-span-3 text-primary-400 italic text-center"> | ||||||
|       <%= gettext("Leave \"Uses left\" blank to make invite unlimited") %> |       <%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %> | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Save"), |     <%= submit(dgettext("actions", "Save"), | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user