Compare commits
	
		
			111 Commits
		
	
	
		
			334d841d57
			...
			weblate
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3fac142454 | |||
| 14615ac107 | |||
| 05b1630a96 | |||
| 1fd3a1bebe | |||
| 6e9bc94f35 | |||
| 4e1cf81242 | |||
| 09e59c94f0 | |||
| 859e5756ae | |||
| 45b46b761d | |||
| bda051ebc8 | |||
| 01fa306429 | |||
| 5cff5d8280 | |||
| 71778d12a6 | |||
| 9b721a170b | |||
| 366a6d160d | |||
| 926d2fe6c2 | |||
| c7bd7238c6 | |||
| e2c17b6b51 | |||
| 20988ac1ec | |||
| 37d101a71e | |||
| 449a92e4b7 | |||
| 5d17ee0a11 | |||
| b6b6cecc0a | |||
| 08916a504f | |||
| 3eda522903 | |||
| 2e6e26006d | |||
| b66d0ea8a1 | |||
| 839e1d7124 | |||
| 76834845a3 | |||
| cc1413ade5 | |||
| 668e4c611b | |||
| ab3d3721d6 | |||
| 7e14f292a6 | |||
| 16a5cb9254 | |||
| f722f9901b | |||
| 6adae82e94 | |||
| a87bf15f72 | |||
| 75c0f8642b | |||
| ec782515ac | |||
| e1cb46cb97 | |||
| 56a49ed2e3 | |||
| f25c151956 | |||
| c2ddc2ae0d | |||
| 33e4d26a3d | |||
| 179d67a896 | |||
| 15354d6004 | |||
| e358cd6e4e | |||
| 202b70dc66 | |||
| b963baa49d | |||
| 70701a27d3 | |||
| 67dc16d222 | |||
| fa35038426 | |||
| d896257602 | |||
| 4ca51a3f53 | |||
| 96b05e8332 | |||
| 557a2cac3d | |||
| e16e04c114 | |||
| bbe4d82303 | |||
| c69d7843ab | |||
| c18f59050c | |||
| 67d688fc1e | |||
|  | 28e5fa56c3 | ||
|  | e301d3dd17 | ||
|  | 4881cf6edb | ||
|  | 6b61c45889 | ||
|  | 4a674a0504 | ||
| 7e6959fb3b | |||
| 22f13b0c57 | |||
| 31024dcc0d | |||
| e843014502 | |||
| 5d146ce6af | |||
| 27cda3733e | |||
| 1965ecba32 | |||
| 69e40c6d18 | |||
| 34b4b24e67 | |||
| 7ebed8d4c0 | |||
| b5619b8606 | |||
| ef28de53a1 | |||
| fcd5dbc605 | |||
| 7738e68292 | |||
| df645a6188 | |||
| bed4fbaf54 | |||
| f94ef0a2ca | |||
| 7cdb8af690 | |||
| 52c4ab45d5 | |||
| a35f43d6df | |||
| 9edeb1e803 | |||
| 7e55446b3e | |||
| 9643e9f46d | |||
| 8466fcd1f9 | |||
| e713a2e108 | |||
| a8fa321040 | |||
| f0536f3030 | |||
| a94d2eebf4 | |||
| cfc56519f5 | |||
| e80c2018be | |||
| 71fdd42d96 | |||
| 8e99a57994 | |||
| 7c42dd8a3a | |||
| 79c97d7502 | |||
| 2e488fa26c | |||
| 2179bd5d86 | |||
| 49628cb9bb | |||
| 8a58d53dc1 | |||
| 9306d0f970 | |||
| 763c86a379 | |||
| b85b1735c0 | |||
| ab1a288928 | |||
| e6ef0a8c68 | |||
| beeaf521c5 | |||
| 8cb6068b85 | 
							
								
								
									
										23
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -17,7 +17,7 @@ steps: | |||||||
|       - .mix |       - .mix | ||||||
|  |  | ||||||
| - name: test | - name: test | ||||||
|   image: elixir:1.14.4-alpine |   image: elixir:1.18.4-otp-28-alpine | ||||||
|   environment: |   environment: | ||||||
|     TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test |     TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test | ||||||
|     HOST: testing.example.tld |     HOST: testing.example.tld | ||||||
| @@ -26,23 +26,25 @@ steps: | |||||||
|     MIX_ENV: test |     MIX_ENV: test | ||||||
|   commands: |   commands: | ||||||
|   - apk add --no-cache build-base npm git |   - apk add --no-cache build-base npm git | ||||||
|   - mix local.rebar --force --if-missing |   - mix local.rebar --force | ||||||
|   - mix local.hex --force --if-missing |   - mix local.hex --force | ||||||
|   - mix deps.get |   - mix deps.get | ||||||
|   - npm set cache .npm |   - npm set cache .npm | ||||||
|   - npm --prefix ./assets ci --no-audit --prefer-offline |   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||||
|   - npm run --prefix ./assets deploy |   - mix do phx.digest, gettext.extract, assets.deploy | ||||||
|   - mix do phx.digest, gettext.extract |  | ||||||
|   - mix test.all |   - mix test.all | ||||||
|  |  | ||||||
| - name: build and publish stable | - name: build and publish stable | ||||||
|   image: thegeeklab/drone-docker-buildx |   image: plugins/docker | ||||||
|   privileged: true |   privileged: true | ||||||
|   settings: |   settings: | ||||||
|     repo: shibaobun/cannery |     repo: shibaobun/cannery | ||||||
|     purge: true |     purge: true | ||||||
|     compress: true |     compress: true | ||||||
|     platforms: linux/amd64,linux/arm/v7 |     platforms: | ||||||
|  |       - linux/amd64 | ||||||
|  |       - linux/arm64 | ||||||
|  |       - linux/arm/v7 | ||||||
|     username: |     username: | ||||||
|       from_secret: docker_username |       from_secret: docker_username | ||||||
|     password: |     password: | ||||||
| @@ -53,13 +55,16 @@ steps: | |||||||
|     - stable |     - stable | ||||||
|  |  | ||||||
| - name: build and publish tagged version | - name: build and publish tagged version | ||||||
|   image: thegeeklab/drone-docker-buildx |   image: plugins/docker | ||||||
|   privileged: true |   privileged: true | ||||||
|   settings: |   settings: | ||||||
|     repo: shibaobun/cannery |     repo: shibaobun/cannery | ||||||
|     purge: true |     purge: true | ||||||
|     compress: true |     compress: true | ||||||
|     platforms: linux/amd64,linux/arm/v7 |     platforms: | ||||||
|  |       - linux/amd64 | ||||||
|  |       - linux/arm64 | ||||||
|  |       - linux/arm/v7 | ||||||
|     username: |     username: | ||||||
|       from_secret: docker_username |       from_secret: docker_username | ||||||
|     password: |     password: | ||||||
|   | |||||||
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -28,10 +28,11 @@ npm-debug.log | |||||||
| # The directory NPM downloads your dependencies sources to. | # The directory NPM downloads your dependencies sources to. | ||||||
| /assets/node_modules/ | /assets/node_modules/ | ||||||
|  |  | ||||||
| # Since we are building assets from assets/, | # Ignore assets that are produced by build tools. | ||||||
| # we ignore priv/static. You may want to comment | /priv/static/assets/ | ||||||
| # this depending on your deployment strategy. |  | ||||||
| /priv/static/ | # Ignore digested assets cache. | ||||||
|  | /priv/static/cache_manifest.json | ||||||
|  |  | ||||||
| # vs code | # vs code | ||||||
| .elixir_ls/ | .elixir_ls/ | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| elixir 1.14.4-otp-25 | elixir 1.18.4-otp-28 | ||||||
| erlang 25.3 | erlang 28.0.1 | ||||||
| nodejs 18.15.0 | nodejs 24.3.0 | ||||||
|   | |||||||
							
								
								
									
										65
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										65
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,6 +1,71 @@ | |||||||
|  | # v0.9.16 | ||||||
|  | - Fix bug with ammo type selector | ||||||
|  | - Add a row number column to all tables | ||||||
|  |  | ||||||
|  | # v0.9.15 | ||||||
|  | - Update deps | ||||||
|  | - Add better reverse proxy and database upgrade instructions | ||||||
|  |  | ||||||
|  | # v0.9.14 | ||||||
|  | - Update deps | ||||||
|  | - Fix wrapping issues with search bars | ||||||
|  | - Improve accuracy of timestamps | ||||||
|  |  | ||||||
|  | # v0.9.13 | ||||||
|  | - Add button to resend email verification email | ||||||
|  | - Move staging to container, rather than ammo | ||||||
|  | - Add date restriction dropdown to range page | ||||||
|  | - Fix dates not rendering properly in table | ||||||
|  | - Update deps | ||||||
|  |  | ||||||
|  | # v0.9.12 | ||||||
|  | - Allow filtering ammo types when creating new packs | ||||||
|  | - Add SlimSelect to select elements with user content | ||||||
|  | - Fix registration page not offering all translations | ||||||
|  | - Update deps | ||||||
|  |  | ||||||
|  | # v0.9.11 | ||||||
|  | - Fix an issue with emails not being able to be sent for real this time | ||||||
|  | - Fix some dropdowns not filling in the correct data | ||||||
|  | - Add debounces to more fields | ||||||
|  | - Update deps | ||||||
|  |  | ||||||
|  | # v0.9.10 | ||||||
|  | - Fix issue with logger failing on oban exceptions | ||||||
|  | - Fix an issue with emails not being able to be sent | ||||||
|  | - Update deps | ||||||
|  |  | ||||||
|  | # v0.9.9 | ||||||
|  | - Actually fix bar graph | ||||||
|  |  | ||||||
|  | # v0.9.8 | ||||||
|  | - Make bar graph ignore empty days | ||||||
|  | - Update dependencies | ||||||
|  |  | ||||||
|  | # v0.9.7 | ||||||
|  | - Fix margin on bottom of page | ||||||
|  | - Use bar graph instead of line graph | ||||||
|  | - Improve login page autocomplete behavior | ||||||
|  |  | ||||||
|  | # v0.9.6 | ||||||
|  | - Make ammo packs in containers directly navigable in table view | ||||||
|  | - Update dependencies | ||||||
|  |  | ||||||
|  | # v0.9.5 | ||||||
|  | - Update dependencies | ||||||
|  |  | ||||||
|  | # v0.9.4 | ||||||
|  | - Code quality fixes | ||||||
|  | - Fix error/404 pages not rendering properly | ||||||
|  | - Update dependencies | ||||||
|  | - Fix Range page title | ||||||
|  |  | ||||||
| # v0.9.3 | # v0.9.3 | ||||||
| - Update dependencies | - Update dependencies | ||||||
| - Add pack lot number to search | - Add pack lot number to search | ||||||
|  | - Improve tests | ||||||
|  | - Change invite path slightly | ||||||
|  | - Disable arm builds since ci fails to build | ||||||
|  |  | ||||||
| # v0.9.2 | # v0.9.2 | ||||||
| - Add lot number to packs | - Add lot number to packs | ||||||
|   | |||||||
| @@ -10,6 +10,9 @@ status](https://weblate.bubbletea.dev/widgets/cannery/-/287x66-black.png)](https | |||||||
|  |  | ||||||
| If you're multilingual, this project can use your translations! Visit | If you're multilingual, this project can use your translations! Visit | ||||||
| [weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information. | [weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information. | ||||||
|  | Also, if your language isn't displayed here, I'd love to add that language so | ||||||
|  | you can start! Please contact me at | ||||||
|  | (shibao@bubbletea.dev)[mailto:shibao@bubbletea.dev] and let me know! | ||||||
|  |  | ||||||
| ## Style Tips | ## Style Tips | ||||||
|  |  | ||||||
| @@ -127,7 +130,7 @@ In `test` mode (or in the Docker container), Cannery will listen for the same en | |||||||
| In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime: | In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime: | ||||||
|  |  | ||||||
| - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | ||||||
|   with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. |   with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start. | ||||||
| - `SMTP_HOST`: The url for your SMTP email provider. Must be set | - `SMTP_HOST`: The url for your SMTP email provider. Must be set | ||||||
| - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. | - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. | ||||||
| - `SMTP_USERNAME`: The username for your SMTP relay. Must be set! | - `SMTP_USERNAME`: The username for your SMTP relay. Must be set! | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| FROM elixir:1.14.4-alpine AS build | FROM elixir:1.18.4-otp-28-alpine AS build | ||||||
|  |  | ||||||
| # install build dependencies | # install build dependencies | ||||||
| RUN apk add --no-cache build-base npm git python3 | RUN apk add --no-cache build-base npm git python3 | ||||||
| @@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3 | |||||||
| WORKDIR /app | WORKDIR /app | ||||||
|  |  | ||||||
| # install hex + rebar | # install hex + rebar | ||||||
| RUN mix local.hex --force && \ | RUN mix local.rebar --force && \ | ||||||
|     mix local.rebar --force |     mix local.hex --force | ||||||
|  |  | ||||||
| # set build ENV | # set build ENV | ||||||
| ENV MIX_ENV=prod | ENV MIX_ENV=prod | ||||||
| @@ -25,24 +25,25 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error | |||||||
| COPY lib lib | COPY lib lib | ||||||
| COPY priv priv | COPY priv priv | ||||||
| COPY assets assets | COPY assets assets | ||||||
| RUN npm run --prefix ./assets deploy |  | ||||||
| RUN mix do phx.digest, gettext.extract | RUN mix do phx.digest, gettext.extract | ||||||
|  |  | ||||||
| # compile and build release | # compile and build release | ||||||
| # uncomment COPY if rel/ exists | # uncomment COPY if rel/ exists | ||||||
| # COPY rel rel | # COPY rel rel | ||||||
| RUN mix do compile, release | RUN mix do assets.deploy, compile, release | ||||||
|  |  | ||||||
| # prepare release image | # prepare release image | ||||||
| FROM alpine:latest AS app | FROM alpine:latest AS app | ||||||
|  |  | ||||||
| RUN apk upgrade --no-cache && \ | RUN apk upgrade --no-cache && \ | ||||||
|     apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs |     apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs | ||||||
|  |  | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
|  |  | ||||||
| RUN chown nobody:nobody /app | RUN chown nobody:nobody /app | ||||||
|  |  | ||||||
|  | ENV MIX_ENV=prod | ||||||
|  |  | ||||||
| USER nobody:nobody | USER nobody:nobody | ||||||
|  |  | ||||||
| COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./ | COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./ | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
								
							| @@ -46,6 +46,16 @@ ports: | |||||||
| ``` | ``` | ||||||
| and reverse proxy to `http://localhost:4000`. | and reverse proxy to `http://localhost:4000`. | ||||||
|  |  | ||||||
|  | If you don't already have a reverse proxy on the machine, I recommend installing | ||||||
|  | [Nginx Proxy Manager](https://nginxproxymanager.com/), which is a GUI for Nginx | ||||||
|  | that makes it easy to configure and modify as your hosting needs change. By | ||||||
|  | adding NPM to cannery's `docker-compose.yml`, you can avoid needing to bind any | ||||||
|  | ports to your machine and have all the internal traffic routed through the | ||||||
|  | generated docker network, which can be a bit more secure. The example | ||||||
|  | configuration is commented out in the `docker-compose.yml` file, and more | ||||||
|  | information can be found on their documentation | ||||||
|  | [here](https://nginxproxymanager.com/setup/). | ||||||
|  |  | ||||||
| # Configuration | # Configuration | ||||||
|  |  | ||||||
| You can use the following environment variables to configure Cannery in | You can use the following environment variables to configure Cannery in | ||||||
| @@ -60,7 +70,7 @@ You can use the following environment variables to configure Cannery in | |||||||
|   Defaults to `false`. |   Defaults to `false`. | ||||||
| - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. | - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. | ||||||
| - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | ||||||
|   with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. |   with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start. | ||||||
| - `REGISTRATION`: Controls if user sign-up should be invite only or set to | - `REGISTRATION`: Controls if user sign-up should be invite only or set to | ||||||
|   public. Set to `public` to enable public registration. Defaults to `invite`. |   public. Set to `public` to enable public registration. Defaults to `invite`. | ||||||
| - `LOCALE`: Sets a custom default locale. Defaults to `en_US` | - `LOCALE`: Sets a custom default locale. Defaults to `en_US` | ||||||
| @@ -74,6 +84,20 @@ You can use the following environment variables to configure Cannery in | |||||||
|   `no-reply@HOST` where `HOST` was previously defined. |   `no-reply@HOST` where `HOST` was previously defined. | ||||||
| - `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Cannery". | - `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Cannery". | ||||||
|  |  | ||||||
|  | # Upgrading the Database | ||||||
|  |  | ||||||
|  | Typically, PostgreSQL updates can improve the performance of the database, and | ||||||
|  | the cannery app. However, these require some additional maintenance. While the | ||||||
|  | typical method is to manually dump and restore the database using the `pg_dump` | ||||||
|  | tool, I recommend using the | ||||||
|  | [pgautoupgrade tool](https://github.com/pgautoupgrade/docker-pgautoupgrade), | ||||||
|  | which can perform this for you automatically. In the `docker-compose.yml` file, | ||||||
|  | you can do this easily by switching the `image:` value from for example, | ||||||
|  | `postgres:13` to `pgautoupgrade/pgautoupgrade:17-alpine` and rerun | ||||||
|  | `docker-compose up -d`. This will automatically migrate your database to | ||||||
|  | Postgres 17, and then you can switch back to the original `postgres:17` image | ||||||
|  | for additional performance, or keep using the upgrade image if you'd like. | ||||||
|  |  | ||||||
| # Contribution | # Contribution | ||||||
|  |  | ||||||
| Contributions are greatly appreciated, no ability to code needed! You can browse | Contributions are greatly appreciated, no ability to code needed! You can browse | ||||||
| @@ -94,6 +118,7 @@ license can be found at | |||||||
|  |  | ||||||
| # Links | # Links | ||||||
|  |  | ||||||
|  | - [Website](https://cannery.app): Project website | ||||||
| - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature | - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature | ||||||
|   requests and bug reports |   requests and bug reports | ||||||
| - [Github](https://github.com/shibaobun/cannery): Source code mirror, please | - [Github](https://github.com/shibaobun/cannery): Source code mirror, please | ||||||
|   | |||||||
| @@ -1,5 +0,0 @@ | |||||||
| { |  | ||||||
|     "presets": [ |  | ||||||
|         "@babel/preset-env" |  | ||||||
|     ] |  | ||||||
| } |  | ||||||
| @@ -1,154 +0,0 @@ | |||||||
| @import "tailwindcss/base"; |  | ||||||
| @import "tailwindcss/components"; |  | ||||||
| @import "tailwindcss/utilities"; |  | ||||||
|  |  | ||||||
| $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; |  | ||||||
| @import "@fortawesome/fontawesome-free/scss/fontawesome"; |  | ||||||
| @import "@fortawesome/fontawesome-free/scss/regular"; |  | ||||||
| @import "@fortawesome/fontawesome-free/scss/solid"; |  | ||||||
| @import "@fortawesome/fontawesome-free/scss/brands"; |  | ||||||
|  |  | ||||||
| @import "components"; |  | ||||||
|  |  | ||||||
| /* fix firefox scrollbars */ |  | ||||||
| * { |  | ||||||
|   scrollbar-width: auto; |  | ||||||
|   scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .fa-fade { |  | ||||||
|   animation: pulse 1s ease-in-out 0s infinite alternate; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @keyframes pulse { |  | ||||||
|   0% { scale: 0.95; opacity: 0.5; } |  | ||||||
|   100% { scale: 1.0; opacity: 1; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // disconnect toast |  | ||||||
| .phx-connected > #disconnect { |  | ||||||
|   opacity: 0 !important; |  | ||||||
|   pointer-events: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-error > #disconnect { |  | ||||||
|   opacity: 0.95 !important; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* Alerts and form errors used by phx.new */ |  | ||||||
| .alert { |  | ||||||
|   padding: 15px; |  | ||||||
|   margin-bottom: 20px; |  | ||||||
|   border: 1px solid transparent; |  | ||||||
|   border-radius: 4px; |  | ||||||
| } |  | ||||||
| .alert-info { |  | ||||||
|   color: #31708f; |  | ||||||
|   background-color: #d9edf7; |  | ||||||
|   border-color: #bce8f1; |  | ||||||
| } |  | ||||||
| .alert-warning { |  | ||||||
|   color: #8a6d3b; |  | ||||||
|   background-color: #fcf8e3; |  | ||||||
|   border-color: #faebcc; |  | ||||||
| } |  | ||||||
| .alert-danger { |  | ||||||
|   color: #a94442; |  | ||||||
|   background-color: #f2dede; |  | ||||||
|   border-color: #ebccd1; |  | ||||||
| } |  | ||||||
| .alert p { |  | ||||||
|   margin-bottom: 0; |  | ||||||
| } |  | ||||||
| .alert:empty { |  | ||||||
|   display: none; |  | ||||||
| } |  | ||||||
| .invalid-feedback { |  | ||||||
|   color: #a94442; |  | ||||||
|   display: block; |  | ||||||
|   margin: -1rem 0 2rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* LiveView specific classes for your customization */ |  | ||||||
| .phx-no-feedback.invalid-feedback, |  | ||||||
| .phx-no-feedback .invalid-feedback { |  | ||||||
|   display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-click-loading { |  | ||||||
|   opacity: 0.5; |  | ||||||
|   transition: opacity 1s ease-out; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-loading{ |  | ||||||
|   cursor: wait; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-modal { |  | ||||||
|   opacity: 1!important; |  | ||||||
|   position: fixed; |  | ||||||
|   z-index: 1; |  | ||||||
|   left: 0; |  | ||||||
|   top: 0; |  | ||||||
|   width: 100%; |  | ||||||
|   height: 100%; |  | ||||||
|   overflow: auto; |  | ||||||
|   background-color: rgba(0,0,0,0.4); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-modal-content { |  | ||||||
|   background-color: #fefefe; |  | ||||||
|   margin: 15vh auto; |  | ||||||
|   padding: 20px; |  | ||||||
|   border: 1px solid #888; |  | ||||||
|   width: 80%; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-modal-close { |  | ||||||
|   color: #aaa; |  | ||||||
|   float: right; |  | ||||||
|   font-size: 28px; |  | ||||||
|   font-weight: bold; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .phx-modal-close:hover, |  | ||||||
| .phx-modal-close:focus { |  | ||||||
|   color: black; |  | ||||||
|   text-decoration: none; |  | ||||||
|   cursor: pointer; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .fade-in-scale { |  | ||||||
|   animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .fade-out-scale { |  | ||||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .fade-in { |  | ||||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; |  | ||||||
| } |  | ||||||
| .fade-out { |  | ||||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @keyframes fade-in-scale-keys{ |  | ||||||
|   0% { scale: 0.95; opacity: 0; } |  | ||||||
|   100% { scale: 1.0; opacity: 1; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @keyframes fade-out-scale-keys{ |  | ||||||
|   0% { scale: 1.0; opacity: 1; } |  | ||||||
|   100% { scale: 0.95; opacity: 0; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @keyframes fade-in-keys{ |  | ||||||
|   0% { opacity: 0; } |  | ||||||
|   100% { opacity: 1; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @keyframes fade-out-keys{ |  | ||||||
|   0% { opacity: 1; } |  | ||||||
|   100% { opacity: 0; } |  | ||||||
| } |  | ||||||
| @@ -1,57 +0,0 @@ | |||||||
| @layer components { |  | ||||||
|   .input { |  | ||||||
|     @apply rounded-lg px-4 py-2 border focus:outline-none; |  | ||||||
|     @apply shadow-sm focus:shadow-lg; |  | ||||||
|     @apply transition-all duration-300 ease-in-out; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .input-primary { |  | ||||||
|     @apply border-primary-500 hover:border-primary-600 active:border-primary-600; |  | ||||||
|     @apply text-black; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .checkbox { |  | ||||||
|     -ms-transform: scale(1.5); |  | ||||||
|     -moz-transform: scale(1.5); |  | ||||||
|     -webkit-transform: scale(1.5); |  | ||||||
|     -o-transform: scale(1.5); |  | ||||||
|     transform: scale(1.5); |  | ||||||
|     padding: 10px; |  | ||||||
|     margin: 1em auto; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .title { |  | ||||||
|     @apply leading-5 tracking-wide; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .btn { |  | ||||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; |  | ||||||
|     @apply shadow-sm focus:shadow-lg; |  | ||||||
|     @apply transition-all duration-300 ease-in-out; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .btn-primary { |  | ||||||
|     @apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600; |  | ||||||
|     @apply border-primary-500 focus:border-primary-600 active:border-primary-600; |  | ||||||
|     @apply text-white; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .btn-alert { |  | ||||||
|     @apply bg-red-600 focus:bg-red-700 active:bg-red-800; |  | ||||||
|     @apply border-red-600 focus:border-red-700 active:border-red-800; |  | ||||||
|     @apply text-white; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .hr { |  | ||||||
|     @apply border border-primary-300 w-full max-w-2xl; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .hr-light { |  | ||||||
|     @apply border border-white w-full max-w-2xl; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .link { |  | ||||||
|     @apply hover:underline; |  | ||||||
|     @apply transition-colors duration-500 ease-in-out; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										266
									
								
								assets/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								assets/css/style.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,266 @@ | |||||||
|  | @import "tailwindcss" source("../.."); | ||||||
|  |  | ||||||
|  | @theme { | ||||||
|  |   --color-primary-50: oklch(0.985 0.002 247.839); | ||||||
|  |   --color-primary-100: oklch(0.967 0.003 264.542); | ||||||
|  |   --color-primary-200: oklch(0.928 0.006 264.531); | ||||||
|  |   --color-primary-300: oklch(0.872 0.01 258.338); | ||||||
|  |   --color-primary-400: oklch(0.707 0.022 261.325); | ||||||
|  |   --color-primary-500: oklch(0.551 0.027 264.364); | ||||||
|  |   --color-primary-600: oklch(0.446 0.03 256.802); | ||||||
|  |   --color-primary-700: oklch(0.373 0.034 259.733); | ||||||
|  |   --color-primary-800: oklch(0.278 0.033 256.848); | ||||||
|  |   --color-primary-900: oklch(0.21 0.034 264.665); | ||||||
|  |   --color-primary-950: oklch(0.13 0.028 261.692); | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @import "@fortawesome/fontawesome-free/css/fontawesome" source("../.."); | ||||||
|  | @import "@fortawesome/fontawesome-free/css/regular" source("../.."); | ||||||
|  | @import "@fortawesome/fontawesome-free/css/solid" source("../.."); | ||||||
|  | @import "@fortawesome/fontawesome-free/css/brands" source("../.."); | ||||||
|  |  | ||||||
|  | @import "slim-select/styles" source("../.."); | ||||||
|  |  | ||||||
|  | /* fix firefox scrollbars */ | ||||||
|  | * { | ||||||
|  |   scrollbar-width: auto; | ||||||
|  |   scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fa-fade { | ||||||
|  |   animation: pulse 1s ease-in-out 0s infinite alternate; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes pulse { | ||||||
|  |   0% { scale: 0.95; opacity: 0.5; } | ||||||
|  |   100% { scale: 1.0; opacity: 1; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* disconnect toast */ | ||||||
|  | .phx-connected > #disconnect { | ||||||
|  |   opacity: 0 !important; | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-error > #disconnect { | ||||||
|  |   opacity: 0.95 !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Alerts and form errors used by phx.new */ | ||||||
|  | .alert { | ||||||
|  |   padding: 15px; | ||||||
|  |   margin-bottom: 20px; | ||||||
|  |   border: 1px solid transparent; | ||||||
|  |   border-radius: 4px; | ||||||
|  | } | ||||||
|  | .alert-info { | ||||||
|  |   color: #31708f; | ||||||
|  |   background-color: #d9edf7; | ||||||
|  |   border-color: #bce8f1; | ||||||
|  | } | ||||||
|  | .alert-warning { | ||||||
|  |   color: #8a6d3b; | ||||||
|  |   background-color: #fcf8e3; | ||||||
|  |   border-color: #faebcc; | ||||||
|  | } | ||||||
|  | .alert-danger { | ||||||
|  |   color: #a94442; | ||||||
|  |   background-color: #f2dede; | ||||||
|  |   border-color: #ebccd1; | ||||||
|  | } | ||||||
|  | .alert p { | ||||||
|  |   margin-bottom: 0; | ||||||
|  | } | ||||||
|  | .alert:empty { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  | .invalid-feedback { | ||||||
|  |   color: #a94442; | ||||||
|  |   display: block; | ||||||
|  |   margin: -1rem 0 2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* LiveView specific classes for your customization */ | ||||||
|  | .phx-no-feedback.invalid-feedback, | ||||||
|  | .phx-no-feedback .invalid-feedback { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-click-loading { | ||||||
|  |   opacity: 0.5; | ||||||
|  |   transition: opacity 1s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-loading{ | ||||||
|  |   cursor: wait; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-modal { | ||||||
|  |   opacity: 1!important; | ||||||
|  |   position: fixed; | ||||||
|  |   z-index: 1; | ||||||
|  |   left: 0; | ||||||
|  |   top: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   overflow: auto; | ||||||
|  |   background-color: rgba(0,0,0,0.4); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-modal-content { | ||||||
|  |   background-color: #fefefe; | ||||||
|  |   margin: 15vh auto; | ||||||
|  |   padding: 20px; | ||||||
|  |   border: 1px solid #888; | ||||||
|  |   width: 80%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-modal-close { | ||||||
|  |   color: #aaa; | ||||||
|  |   float: right; | ||||||
|  |   font-size: 28px; | ||||||
|  |   font-weight: bold; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .phx-modal-close:hover, | ||||||
|  | .phx-modal-close:focus { | ||||||
|  |   color: black; | ||||||
|  |   text-decoration: none; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fade-in-scale { | ||||||
|  |   animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fade-out-scale { | ||||||
|  |   animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fade-in { | ||||||
|  |   animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; | ||||||
|  | } | ||||||
|  | .fade-out { | ||||||
|  |   animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fade-in-scale-keys{ | ||||||
|  |   0% { scale: 0.95; opacity: 0; } | ||||||
|  |   100% { scale: 1.0; opacity: 1; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fade-out-scale-keys{ | ||||||
|  |   0% { scale: 1.0; opacity: 1; } | ||||||
|  |   100% { scale: 0.95; opacity: 0; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fade-in-keys{ | ||||||
|  |   0% { opacity: 0; } | ||||||
|  |   100% { opacity: 1; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fade-out-keys{ | ||||||
|  |   0% { opacity: 1; } | ||||||
|  |   100% { opacity: 0; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* components */ | ||||||
|  | .input, .ss-main, .ss-content, .ss-search input[type="search"] { | ||||||
|  |   @apply px-4 py-2 rounded-lg border focus:outline-hidden; | ||||||
|  |   @apply shadow-sm focus:shadow-lg; | ||||||
|  |   @apply transition-all duration-300 ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .input-primary { | ||||||
|  |   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||||
|  |   @apply text-black; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .checkbox { | ||||||
|  |   -ms-transform: scale(1.5); | ||||||
|  |   -moz-transform: scale(1.5); | ||||||
|  |   -webkit-transform: scale(1.5); | ||||||
|  |   -o-transform: scale(1.5); | ||||||
|  |   transform: scale(1.5); | ||||||
|  |   padding: 10px; | ||||||
|  |   margin: 1em auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .title { | ||||||
|  |   @apply tracking-wide leading-5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn { | ||||||
|  |   @apply px-4 py-2 rounded-lg focus:outline-hidden; | ||||||
|  |   @apply shadow-sm focus:shadow-lg; | ||||||
|  |   @apply transition-all duration-300 ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-primary { | ||||||
|  |   @apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600; | ||||||
|  |   @apply border-primary-500 focus:border-primary-600 active:border-primary-600; | ||||||
|  |   @apply text-white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-alert { | ||||||
|  |   @apply bg-rose-600 focus:bg-rose-700 active:bg-rose-800; | ||||||
|  |   @apply border-rose-600 focus:border-rose-700 active:border-rose-800; | ||||||
|  |   @apply text-white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hr { | ||||||
|  |   @apply w-full max-w-2xl border border-primary-300; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hr-light { | ||||||
|  |   @apply w-full max-w-2xl border border-white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .link { | ||||||
|  |   @apply hover:underline; | ||||||
|  |   @apply transition-colors duration-500 ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* slim select */ | ||||||
|  | .ss-main.input-primary { | ||||||
|  |   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.input-primary { | ||||||
|  |   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-above { | ||||||
|  |   border-bottom-left-radius: 0px; | ||||||
|  |   border-bottom-right-radius: 0px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-below { | ||||||
|  |   border-top-left-radius: 0px; | ||||||
|  |   border-top-right-radius: 0px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.input-primary .ss-search input[type="search"] { | ||||||
|  |   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-above .ss-search { | ||||||
|  |   padding: var(--ss-spacing-l) 0 0 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-below .ss-search { | ||||||
|  |   padding: 0 0 var(--ss-spacing-l) 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-above .ss-list > *:not(:first-child) { | ||||||
|  |   margin: var(--ss-spacing-l) 0 0 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content.ss-open-below .ss-list > *:not(:last-child) { | ||||||
|  |   margin: 0 0 var(--ss-spacing-l) 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ss-content .ss-list .ss-option { | ||||||
|  |   border-radius: var(--ss-border-radius); | ||||||
|  | } | ||||||
| @@ -1,7 +1,3 @@ | |||||||
| // We import the CSS which is extracted to its own file by esbuild. |  | ||||||
| // Remove this line if you add a your own CSS build pipeline (e.g postcss). |  | ||||||
| import '../css/app.scss' |  | ||||||
|  |  | ||||||
| // If you want to use Phoenix channels, run `mix help phx.gen.channel` | // If you want to use Phoenix channels, run `mix help phx.gen.channel` | ||||||
| // to get started and then uncomment the line below. | // to get started and then uncomment the line below. | ||||||
| // import "./user_socket.js" | // import "./user_socket.js" | ||||||
| @@ -24,16 +20,16 @@ import 'phoenix_html' | |||||||
| // Establish Phoenix Socket and LiveView configuration. | // Establish Phoenix Socket and LiveView configuration. | ||||||
| import { Socket } from 'phoenix' | import { Socket } from 'phoenix' | ||||||
| import { LiveSocket } from 'phoenix_live_view' | import { LiveSocket } from 'phoenix_live_view' | ||||||
| import topbar from 'topbar' |  | ||||||
| import MaintainAttrs from './maintain_attrs' |  | ||||||
| import ShotLogChart from './shot_log_chart' |  | ||||||
| import Date from './date' | import Date from './date' | ||||||
| import DateTime from './datetime' | import DateTime from './datetime' | ||||||
|  | import ShotLogChart from './shot_log_chart' | ||||||
|  | import SlimSelect from './slim_select' | ||||||
|  | import topbar from 'topbar' | ||||||
|  |  | ||||||
| const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') | const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') | ||||||
| const liveSocket = new LiveSocket('/live', Socket, { | const liveSocket = new LiveSocket('/live', Socket, { | ||||||
|   params: { _csrf_token: csrfToken }, |   params: { _csrf_token: csrfToken }, | ||||||
|   hooks: { Date, DateTime, MaintainAttrs, ShotLogChart } |   hooks: { Date, DateTime, ShotLogChart, SlimSelect } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // Show progress bar on live navigation and form submits | // Show progress bar on live navigation and form submits | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| export default { | export default { | ||||||
|   displayDate (el) { |   displayDate (el) { | ||||||
|     const date = |     const date = | ||||||
|       Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }) |       Intl.DateTimeFormat([], { timeZone: 'UTC', dateStyle: 'short' }) | ||||||
|         .format(new Date(el.dateTime)) |         .format(new Date(el.dateTime)) | ||||||
|  |  | ||||||
|     el.innerText = date |     el.innerText = date | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| export default { | export default { | ||||||
|   displayDateTime (el) { |   displayDateTime (el) { | ||||||
|     const date = |     const date = | ||||||
|       Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' }) |       Intl.DateTimeFormat([], { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'long' }) | ||||||
|         .format(new Date(el.dateTime)) |         .format(new Date(el.dateTime)) | ||||||
|  |  | ||||||
|     el.innerText = date |     el.innerText = date | ||||||
|   | |||||||
| @@ -1,11 +0,0 @@ | |||||||
| // maintain user adjusted attributes, like textbox length on phoenix liveview |  | ||||||
| // update. https://github.com/phoenixframework/phoenix_live_view/issues/1011 |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   attrs () { |  | ||||||
|     const attrs = this.el.getAttribute('data-attrs') |  | ||||||
|     if (attrs) { return attrs.split(', ') } else { return [] } |  | ||||||
|   }, |  | ||||||
|   beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) }, |  | ||||||
|   updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) } |  | ||||||
| } |  | ||||||
| @@ -1,13 +1,12 @@ | |||||||
| import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js' | import Chart from 'chart.js/auto' | ||||||
| import 'chartjs-adapter-date-fns' | import 'chartjs-adapter-date-fns' | ||||||
| Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale) |  | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   initalizeChart (el) { |   initalizeChart (el) { | ||||||
|     const data = JSON.parse(el.dataset.chartData) |     const data = JSON.parse(el.dataset.chartData) | ||||||
|  |  | ||||||
|     this.el.chart = new Chart(el, { |     this.el.chart = new Chart(el, { | ||||||
|       type: 'line', |       type: 'bar', | ||||||
|       data: { |       data: { | ||||||
|         datasets: [{ |         datasets: [{ | ||||||
|           label: el.dataset.label, |           label: el.dataset.label, | ||||||
| @@ -51,13 +50,17 @@ export default { | |||||||
|             stacked: true, |             stacked: true, | ||||||
|             grace: '15%', |             grace: '15%', | ||||||
|             ticks: { |             ticks: { | ||||||
|               padding: 15 |               padding: 15, | ||||||
|  |               precision: 0 | ||||||
|             } |             } | ||||||
|           }, |           }, | ||||||
|           x: { |           x: { | ||||||
|             type: 'time', |             type: 'timeseries', | ||||||
|             time: { |             time: { | ||||||
|               unit: 'day' |               unit: 'day' | ||||||
|  |             }, | ||||||
|  |             ticks: { | ||||||
|  |               source: 'data' | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								assets/js/slim_select.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								assets/js/slim_select.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import SlimSelect from 'slim-select' | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   initalizeSlimSelect (el) { | ||||||
|  |     // eslint-disable-next-line no-new | ||||||
|  |     el.slimselect = new SlimSelect({ | ||||||
|  |       select: el, | ||||||
|  |       settings: { | ||||||
|  |         contentPosition: 'fixed' | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     const main = document.querySelector(`.ss-main[data-id="${el.dataset.id}"]`) | ||||||
|  |     main.setAttribute('id', `${el.dataset.id}-main`) | ||||||
|  |     main.setAttribute('phx-update', 'ignore') | ||||||
|  |  | ||||||
|  |     const content = document.querySelector(`.ss-content[data-id="${el.dataset.id}"]`) | ||||||
|  |     content.setAttribute('id', `${el.dataset.id}-content`) | ||||||
|  |     content.setAttribute('phx-update', 'ignore') | ||||||
|  |   }, | ||||||
|  |   updated () { | ||||||
|  |     this.el.slimselect?.destroy() | ||||||
|  |     this.initalizeSlimSelect(this.el) | ||||||
|  |   }, | ||||||
|  |   mounted () { | ||||||
|  |     this.initalizeSlimSelect(this.el) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										23820
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23820
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,49 +1,22 @@ | |||||||
| { | { | ||||||
|   "repository": {}, |  | ||||||
|   "description": " ", |  | ||||||
|   "license": "MIT", |  | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": "v18.15.0", |     "node": "v24.3.0", | ||||||
|     "npm": "9.5.0" |     "npm": "11.4.2" | ||||||
|   }, |   }, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "deploy": "NODE_ENV=production webpack --mode production", |  | ||||||
|     "watch": "webpack --mode development --watch --watch-options-stdin", |  | ||||||
|     "format": "standard --fix", |     "format": "standard --fix", | ||||||
|     "test": "standard" |     "test": "standard" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@fortawesome/fontawesome-free": "^6.4.0", |     "@fortawesome/fontawesome-free": "^6.7.2", | ||||||
|     "chart.js": "^4.2.1", |     "chart.js": "^4.5.0", | ||||||
|     "chartjs-adapter-date-fns": "^3.0.0", |     "chartjs-adapter-date-fns": "^3.0.0", | ||||||
|     "date-fns": "^2.29.3", |     "date-fns": "^4.1.0", | ||||||
|     "phoenix": "file:../deps/phoenix", |     "slim-select": "^2.12.1", | ||||||
|     "phoenix_html": "file:../deps/phoenix_html", |     "topbar": "^3.0.0" | ||||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", |  | ||||||
|     "topbar": "^2.0.1" |  | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@babel/core": "^7.21.4", |     "npm-check-updates": "^18.0.1", | ||||||
|     "@babel/preset-env": "^7.21.4", |     "standard": "^17.1.2" | ||||||
|     "autoprefixer": "^10.4.14", |  | ||||||
|     "babel-loader": "^9.1.2", |  | ||||||
|     "copy-webpack-plugin": "^11.0.0", |  | ||||||
|     "css-loader": "^6.7.3", |  | ||||||
|     "css-minimizer-webpack-plugin": "^5.0.0", |  | ||||||
|     "file-loader": "^6.2.0", |  | ||||||
|     "mini-css-extract-plugin": "^2.7.5", |  | ||||||
|     "npm-check-updates": "^16.10.8", |  | ||||||
|     "postcss": "^8.4.21", |  | ||||||
|     "postcss-import": "^15.1.0", |  | ||||||
|     "postcss-loader": "^7.2.4", |  | ||||||
|     "postcss-preset-env": "^8.3.1", |  | ||||||
|     "sass": "^1.62.0", |  | ||||||
|     "sass-loader": "^13.2.2", |  | ||||||
|     "standard": "^17.0.0", |  | ||||||
|     "tailwindcss": "^3.3.1", |  | ||||||
|     "terser-webpack-plugin": "^5.3.7", |  | ||||||
|     "webpack": "^5.79.0", |  | ||||||
|     "webpack-cli": "^5.0.1", |  | ||||||
|     "webpack-dev-server": "^4.13.2" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,7 +0,0 @@ | |||||||
| module.exports = { |  | ||||||
|   plugins: { |  | ||||||
|     'postcss-import': {}, |  | ||||||
|     tailwindcss: {}, |  | ||||||
|     autoprefixer: {} |  | ||||||
|   } |  | ||||||
| } |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 14 KiB | 
| @@ -1,43 +0,0 @@ | |||||||
| const colors = require('tailwindcss/colors') |  | ||||||
|  |  | ||||||
| module.exports = { |  | ||||||
|   content: [ |  | ||||||
|     '../lib/**/*.{ex,heex,leex,eex}', |  | ||||||
|     './js/**/*.js' |  | ||||||
|   ], |  | ||||||
|   theme: { |  | ||||||
|     colors: { |  | ||||||
|       transparent: 'transparent', |  | ||||||
|       current: 'currentColor', |  | ||||||
|       primary: colors.gray, |  | ||||||
|       black: colors.black, |  | ||||||
|       white: colors.white, |  | ||||||
|       gray: colors.neutral, |  | ||||||
|       indigo: colors.indigo, |  | ||||||
|       red: colors.rose, |  | ||||||
|       yellow: colors.amber |  | ||||||
|     }, |  | ||||||
|     extend: { |  | ||||||
|       spacing: { |  | ||||||
|         128: '32rem', |  | ||||||
|         192: '48rem', |  | ||||||
|         256: '64rem' |  | ||||||
|       }, |  | ||||||
|       minWidth: { |  | ||||||
|         4: '1rem', |  | ||||||
|         8: '2rem', |  | ||||||
|         12: '3rem', |  | ||||||
|         16: '4rem', |  | ||||||
|         20: '8rem' |  | ||||||
|       }, |  | ||||||
|       maxWidth: { |  | ||||||
|         4: '1rem', |  | ||||||
|         8: '2rem', |  | ||||||
|         12: '3rem', |  | ||||||
|         16: '4rem', |  | ||||||
|         20: '8rem' |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   plugins: [] |  | ||||||
| } |  | ||||||
| @@ -1,57 +0,0 @@ | |||||||
| const path = require('path') |  | ||||||
| const glob = require('glob') |  | ||||||
| const MiniCssExtractPlugin = require('mini-css-extract-plugin') |  | ||||||
| const TerserPlugin = require('terser-webpack-plugin') |  | ||||||
| const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') |  | ||||||
| const CopyWebpackPlugin = require('copy-webpack-plugin') |  | ||||||
|  |  | ||||||
| module.exports = (env, options) => { |  | ||||||
|   const devMode = options.mode !== 'production' |  | ||||||
|  |  | ||||||
|   return { |  | ||||||
|     optimization: { |  | ||||||
|       minimizer: [ |  | ||||||
|         new TerserPlugin({ parallel: true, extractComments: true }), |  | ||||||
|         new CssMinimizerPlugin({}) |  | ||||||
|       ] |  | ||||||
|     }, |  | ||||||
|     entry: { |  | ||||||
|       app: glob.sync('./vendor/**/*.js').concat(['./js/app.js']) |  | ||||||
|     }, |  | ||||||
|     output: { |  | ||||||
|       filename: '[name].js', |  | ||||||
|       path: path.resolve(__dirname, '../priv/static/js'), |  | ||||||
|       publicPath: '/js/' |  | ||||||
|     }, |  | ||||||
|     devtool: devMode ? 'eval-cheap-module-source-map' : undefined, |  | ||||||
|     module: { |  | ||||||
|       rules: [ |  | ||||||
|         { |  | ||||||
|           test: /\.js$/, |  | ||||||
|           exclude: /node_modules/, |  | ||||||
|           use: { |  | ||||||
|             loader: 'babel-loader' |  | ||||||
|           } |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           test: /\.s?css$/, |  | ||||||
|           use: [ |  | ||||||
|             MiniCssExtractPlugin.loader, |  | ||||||
|             'css-loader', |  | ||||||
|             'postcss-loader', |  | ||||||
|             'sass-loader' |  | ||||||
|           ] |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, |  | ||||||
|           type: 'asset/resource', |  | ||||||
|           generator: { filename: 'fonts/[name].[ext]' } |  | ||||||
|         } |  | ||||||
|       ] |  | ||||||
|     }, |  | ||||||
|     plugins: [ |  | ||||||
|       new MiniCssExtractPlugin({ filename: '../css/app.css' }), |  | ||||||
|       new CopyWebpackPlugin({ patterns: [{ from: 'static/', to: '../' }] }) |  | ||||||
|     ] |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -8,26 +8,31 @@ | |||||||
| import Config | import Config | ||||||
|  |  | ||||||
| config :cannery, | config :cannery, | ||||||
|   ecto_repos: [Cannery.Repo], |   env: :dev, | ||||||
|   generators: [binary_id: true] |   ecto_repos: [Cannery.Repo] | ||||||
|  |  | ||||||
| config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||||
|  |  | ||||||
| # Configures the endpoint | # Configures the endpoint | ||||||
| config :cannery, CanneryWeb.Endpoint, | config :cannery, CanneryWeb.Endpoint, | ||||||
|  |   adapter: Bandit.PhoenixAdapter, | ||||||
|   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], |   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], | ||||||
|   http: [port: String.to_integer(System.get_env("PORT") || "4000")], |   http: [port: String.to_integer(System.get_env("PORT") || "4000")], | ||||||
|   secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", |   secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", | ||||||
|   render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false], |   render_errors: [ | ||||||
|  |     formats: [html: CanneryWeb.ErrorHTML, json: CanneryWeb.ErrorJSON], | ||||||
|  |     layout: false | ||||||
|  |   ], | ||||||
|   pubsub_server: Cannery.PubSub, |   pubsub_server: Cannery.PubSub, | ||||||
|   live_view: [signing_salt: "zOLgd3lr"] |   live_view: [signing_salt: "zOLgd3lr"] | ||||||
|  |  | ||||||
| config :cannery, Cannery.Application, automigrate: false | config :cannery, Cannery.Application, automigrate: false | ||||||
|  |  | ||||||
| config :cannery, :generators, | config :cannery, :generators, | ||||||
|   migration: true, |  | ||||||
|   binary_id: true, |   binary_id: true, | ||||||
|   sample_binary_id: "11111111-1111-1111-1111-111111111111" |   migration: true, | ||||||
|  |   sample_binary_id: "11111111-1111-1111-1111-111111111111", | ||||||
|  |   timestamp_type: :utc_datetime_usec | ||||||
|  |  | ||||||
| # Configures the mailer | # Configures the mailer | ||||||
| # | # | ||||||
| @@ -51,14 +56,25 @@ config :cannery, Oban, | |||||||
|   queues: [default: 10, mailers: 20] |   queues: [default: 10, mailers: 20] | ||||||
|  |  | ||||||
| # Configure esbuild (the version is required) | # Configure esbuild (the version is required) | ||||||
| # config :esbuild, | config :esbuild, | ||||||
| #   version: "0.14.0", |   version: "0.17.11", | ||||||
| #   default: [ |   cannery: [ | ||||||
| #     args: |     args: | ||||||
| #       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), |       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), | ||||||
| #     cd: Path.expand("../assets", __DIR__), |     cd: Path.expand("../assets", __DIR__), | ||||||
| #     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} |     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} | ||||||
| #   ] |   ] | ||||||
|  |  | ||||||
|  | # Configure tailwind (the version is required) | ||||||
|  | config :tailwind, | ||||||
|  |   version: "4.0.0", | ||||||
|  |   cannery: [ | ||||||
|  |     args: ~w( | ||||||
|  |       --input=css/style.css | ||||||
|  |       --output=../priv/static/assets/style.css | ||||||
|  |     ), | ||||||
|  |     cd: Path.expand("../assets", __DIR__) | ||||||
|  |   ] | ||||||
|  |  | ||||||
| # Configures Elixir's Logger | # Configures Elixir's Logger | ||||||
| config :logger, :console, | config :logger, :console, | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import Config | |||||||
|  |  | ||||||
| # Configure your database | # Configure your database | ||||||
| config :cannery, Cannery.Repo, | config :cannery, Cannery.Repo, | ||||||
|  |   stacktrace: true, | ||||||
|   show_sensitive_data_on_connection_error: true, |   show_sensitive_data_on_connection_error: true, | ||||||
|   pool_size: 10 |   pool_size: 10 | ||||||
|  |  | ||||||
| @@ -12,21 +13,14 @@ config :cannery, Cannery.Repo, | |||||||
| # watchers to your application. For example, we use it | # watchers to your application. For example, we use it | ||||||
| # with esbuild to bundle .js and .css sources. | # with esbuild to bundle .js and .css sources. | ||||||
| config :cannery, CanneryWeb.Endpoint, | config :cannery, CanneryWeb.Endpoint, | ||||||
|  |   http: [ip: {0, 0, 0, 0}, port: 4000], | ||||||
|   check_origin: false, |   check_origin: false, | ||||||
|   code_reloader: true, |   code_reloader: true, | ||||||
|   debug_errors: true, |   debug_errors: true, | ||||||
|   secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", |   secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", | ||||||
|   watchers: [ |   watchers: [ | ||||||
|     # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) |     esbuild: {Esbuild, :install_and_run, [:cannery, ~w(--sourcemap=inline --watch)]}, | ||||||
|     # esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} |     tailwind: {Tailwind, :install_and_run, [:cannery, ~w(--watch)]} | ||||||
|     node: [ |  | ||||||
|       "node_modules/webpack/bin/webpack.js", |  | ||||||
|       "--mode", |  | ||||||
|       "development", |  | ||||||
|       "--watch", |  | ||||||
|       "--watch-options-stdin", |  | ||||||
|       cd: Path.expand("../assets", __DIR__) |  | ||||||
|     ] |  | ||||||
|   ] |   ] | ||||||
|  |  | ||||||
| # ## SSL Support | # ## SSL Support | ||||||
| @@ -57,7 +51,7 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
| config :cannery, CanneryWeb.Endpoint, | config :cannery, CanneryWeb.Endpoint, | ||||||
|   live_reload: [ |   live_reload: [ | ||||||
|     patterns: [ |     patterns: [ | ||||||
|       ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", |       ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", | ||||||
|       ~r"priv/gettext/.*(po)$", |       ~r"priv/gettext/.*(po)$", | ||||||
|       ~r"lib/cannery_web/*/.*(ex)$" |       ~r"lib/cannery_web/*/.*(ex)$" | ||||||
|     ] |     ] | ||||||
| @@ -73,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20 | |||||||
|  |  | ||||||
| # Initialize plugs at runtime for faster development compilation | # Initialize plugs at runtime for faster development compilation | ||||||
| config :phoenix, :plug_init_mode, :runtime | config :phoenix, :plug_init_mode, :runtime | ||||||
|  |  | ||||||
|  | config :phoenix_live_view, | ||||||
|  |   # Include HEEx debug annotations as HTML comments in rendered markup | ||||||
|  |   debug_heex_annotations: true, | ||||||
|  |   # Enable helpful, but potentially expensive runtime checks | ||||||
|  |   enable_expensive_runtime_checks: true | ||||||
|   | |||||||
| @@ -14,6 +14,8 @@ config :cannery, CanneryWeb.Endpoint, cache_static_manifest: "priv/static/cache_ | |||||||
| # Do not print debug messages in production | # Do not print debug messages in production | ||||||
| config :logger, level: :info | config :logger, level: :info | ||||||
|  |  | ||||||
|  | config :cannery, env: :prod | ||||||
|  |  | ||||||
| # ## SSL Support | # ## SSL Support | ||||||
| # | # | ||||||
| # To get SSL working, you will need to add the `https` key | # To get SSL working, you will need to add the `https` key | ||||||
|   | |||||||
| @@ -7,13 +7,23 @@ import Config | |||||||
| # any compile-time configuration in here, as it won't be applied. | # any compile-time configuration in here, as it won't be applied. | ||||||
| # The block below contains prod specific runtime configuration. | # The block below contains prod specific runtime configuration. | ||||||
|  |  | ||||||
| # Start the phoenix server if environment is set and running in a release | # ## Using releases | ||||||
| if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do | # | ||||||
|  | # If you use `mix release`, you need to explicitly enable the server | ||||||
|  | # by passing the PHX_SERVER=true when you start it: | ||||||
|  | # | ||||||
|  | #     PHX_SERVER=true bin/cannery start | ||||||
|  | # | ||||||
|  | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` | ||||||
|  | # script that automatically sets the env var above. | ||||||
|  | if System.get_env("PHX_SERVER") do | ||||||
|   config :cannery, CanneryWeb.Endpoint, server: true |   config :cannery, CanneryWeb.Endpoint, server: true | ||||||
| end | end | ||||||
|  |  | ||||||
| config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | ||||||
|  |  | ||||||
|  | config :cannery, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") | ||||||
|  |  | ||||||
| # Set default locale | # Set default locale | ||||||
| config :gettext, :default_locale, System.get_env("LOCALE", "en_US") | config :gettext, :default_locale, System.get_env("LOCALE", "en_US") | ||||||
|  |  | ||||||
| @@ -68,7 +78,7 @@ if config_env() == :prod do | |||||||
|     System.get_env("SECRET_KEY_BASE") || |     System.get_env("SECRET_KEY_BASE") || | ||||||
|       raise """ |       raise """ | ||||||
|       environment variable SECRET_KEY_BASE is missing. |       environment variable SECRET_KEY_BASE is missing. | ||||||
|       You can generate one by calling: mix phx.gen.secret |       You can generate one by calling: priv/random.sh | ||||||
|       """ |       """ | ||||||
|  |  | ||||||
|   config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base |   config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base | ||||||
|   | |||||||
| @@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1 | |||||||
| # to provide built-in test partitioning in CI environment. | # to provide built-in test partitioning in CI environment. | ||||||
| # Run `mix help test` for more information. | # Run `mix help test` for more information. | ||||||
| config :cannery, Cannery.Repo, | config :cannery, Cannery.Repo, | ||||||
|  |   pool_size: 10, | ||||||
|   pool: Ecto.Adapters.SQL.Sandbox, |   pool: Ecto.Adapters.SQL.Sandbox, | ||||||
|   pool_size: 10 |   timeout: 60000 | ||||||
|  |  | ||||||
| # We don't run a server during test. If one is required, | # We don't run a server during test. If one is required, | ||||||
| # you can enable the server option below. | # you can enable the server option below. | ||||||
| @@ -19,6 +20,8 @@ config :cannery, CanneryWeb.Endpoint, | |||||||
|   secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", |   secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", | ||||||
|   server: false |   server: false | ||||||
|  |  | ||||||
|  | config :cannery, env: :test | ||||||
|  |  | ||||||
| # In test we don't send emails. | # In test we don't send emails. | ||||||
| config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test | config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test | ||||||
|  |  | ||||||
| @@ -26,10 +29,10 @@ config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test | |||||||
| config :cannery, Cannery.Accounts, registration: "public" | config :cannery, Cannery.Accounts, registration: "public" | ||||||
|  |  | ||||||
| # Print only warnings and errors during test | # Print only warnings and errors during test | ||||||
| config :logger, level: :warn | config :logger, level: :warning | ||||||
|  |  | ||||||
| # Initialize plugs at runtime for faster test compilation | # Initialize plugs at runtime for faster test compilation | ||||||
| config :phoenix, :plug_init_mode, :runtime | config :phoenix, :plug_init_mode, :runtime | ||||||
|  |  | ||||||
| # Disable Oban | # Disable Oban | ||||||
| config :cannery, Oban, queues: false, plugins: false | config :cannery, Oban, queues: false, plugins: false, testing: :manual | ||||||
|   | |||||||
| @@ -32,7 +32,12 @@ services: | |||||||
|       - cannery-db |       - cannery-db | ||||||
|  |  | ||||||
|   cannery-db: |   cannery-db: | ||||||
|     image: postgres:13 |     image: postgres:17 | ||||||
|  |     # To upgrade your database from a previous version, replace the existing | ||||||
|  |     # image with the following line. Once the container has been booted and your | ||||||
|  |     # database updated, you can revert back to the original `postgres:17` image | ||||||
|  |     # if you'd like | ||||||
|  |     # image: pgautoupgrade/pgautoupgrade:17-alpine | ||||||
|     container_name: cannery-db |     container_name: cannery-db | ||||||
|     environment: |     environment: | ||||||
|       - POSTGRES_USER="postgres" |       - POSTGRES_USER="postgres" | ||||||
| @@ -40,4 +45,47 @@ services: | |||||||
|       - POSTGRES_DB="cannery" |       - POSTGRES_DB="cannery" | ||||||
|     restart: always |     restart: always | ||||||
|     volumes: |     volumes: | ||||||
|       - ./data:/var/lib/postgresql/data |       - ./cannery/data:/var/lib/postgresql/data | ||||||
|  |  | ||||||
|  |   # Optional, if you don't have a reverse proxy set up already. You can replace | ||||||
|  |   # this with any other reverse proxy like nginx, apache or caddy if you would | ||||||
|  |   # like something more minimalist. | ||||||
|  |  | ||||||
|  |   # nginx: | ||||||
|  |   #   image: jc21/nginx-proxy-manager | ||||||
|  |   #   container_name: nginx | ||||||
|  |   #   restart: on-failure | ||||||
|  |   #   ports: | ||||||
|  |   #     - 443:443 | ||||||
|  |   #     - 80:80 | ||||||
|  |   #    # Access via http://localhost:81, although once you access this, you can | ||||||
|  |   #    # reverse proxy itself to a domain name to control it from anywhere if | ||||||
|  |   #    # you'd like, and then you can remove the following line | ||||||
|  |   #     - 127.0.0.1:81:81 | ||||||
|  |   #   # expose: | ||||||
|  |   #   #   - 81 | ||||||
|  |   #   environment: | ||||||
|  |   #     # DISABLE_IPV6: 'true' | ||||||
|  |   #     DB_MYSQL_HOST: "nginx-db" | ||||||
|  |   #     DB_MYSQL_NAME: "nginx" | ||||||
|  |   #     DB_MYSQL_PASSWORD: "my-password" | ||||||
|  |   #     DB_MYSQL_PORT: 3306 | ||||||
|  |   #     DB_MYSQL_USER: "nginx" | ||||||
|  |   #     PGID: 9000 | ||||||
|  |   #     PUID: 9000 | ||||||
|  |   #   volumes: | ||||||
|  |   #     - /etc/letsencrypt:/etc/letsencrypt | ||||||
|  |   #     - ./nginx/data:/data | ||||||
|  |   #     - /tmp:/tmp | ||||||
|  |  | ||||||
|  |   # nginx-db: | ||||||
|  |   #   image: 'jc21/mariadb-aria:latest' | ||||||
|  |   #   container_name: nginx-db | ||||||
|  |   #   restart: on-failure | ||||||
|  |   #   environment: | ||||||
|  |   #     MYSQL_DATABASE: 'nginx' | ||||||
|  |   #     MYSQL_PASSWORD: 'my-password' | ||||||
|  |   #     MYSQL_ROOT_PASSWORD: 'my-root-password' | ||||||
|  |   #     MYSQL_USER: 'nginx' | ||||||
|  |   #   volumes: | ||||||
|  |   #     - ./nginx/mysql:/var/lib/mysql | ||||||
|   | |||||||
| @@ -6,4 +6,35 @@ defmodule Cannery do | |||||||
|   Contexts are also responsible for managing your data, regardless |   Contexts are also responsible for managing your data, regardless | ||||||
|   if it comes from the database, an external API or others. |   if it comes from the database, an external API or others. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|  |   def context do | ||||||
|  |     quote do | ||||||
|  |       use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |       import Ecto.Query | ||||||
|  |       alias Cannery.Accounts.User | ||||||
|  |       alias Cannery.Repo | ||||||
|  |       alias Ecto.{Changeset, Multi, Queryable, UUID} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def schema do | ||||||
|  |     quote do | ||||||
|  |       use Ecto.Schema | ||||||
|  |       use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |       import Ecto.{Changeset, Query} | ||||||
|  |       alias Cannery.Accounts.User | ||||||
|  |       alias Ecto.{Association, Changeset, Queryable, UUID} | ||||||
|  |  | ||||||
|  |       @primary_key {:id, :binary_id, autogenerate: true} | ||||||
|  |       @foreign_key_type :binary_id | ||||||
|  |       @timestamps_opts [type: :utc_datetime_usec] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   When used, dispatch to the appropriate context/schema/etc. | ||||||
|  |   """ | ||||||
|  |   defmacro __using__(which) when is_atom(which) do | ||||||
|  |     apply(__MODULE__, which, []) | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -3,10 +3,9 @@ defmodule Cannery.Accounts do | |||||||
|   The Accounts context. |   The Accounts context. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |   use Cannery, :context | ||||||
|   alias Cannery.{Mailer, Repo} |   alias Cannery.Mailer | ||||||
|   alias Cannery.Accounts.{Invite, Invites, User, UserToken} |   alias Cannery.Accounts.{Invite, Invites, UserToken} | ||||||
|   alias Ecto.{Changeset, Multi} |  | ||||||
|   alias Oban.Job |   alias Oban.Job | ||||||
|  |  | ||||||
|   ## Database getters |   ## Database getters | ||||||
| @@ -219,7 +218,7 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|     with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), |     with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), | ||||||
|          %UserToken{sent_to: email} <- Repo.one(query), |          %UserToken{sent_to: email} <- Repo.one(query), | ||||||
|          {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do |          {:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||||
|       :ok |       :ok | ||||||
|     else |     else | ||||||
|       _error_tuple -> :error |       _error_tuple -> :error | ||||||
| @@ -404,15 +403,15 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> is_admin?(%User{role: :admin}) |       iex> admin?(%User{role: :admin}) | ||||||
|       true |       true | ||||||
|  |  | ||||||
|       iex> is_admin?(%User{}) |       iex> admin?(%User{}) | ||||||
|       false |       false | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec is_admin?(User.t()) :: boolean() |   @spec admin?(User.t()) :: boolean() | ||||||
|   def is_admin?(%User{id: user_id}) do |   def admin?(%User{id: user_id}) do | ||||||
|     Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) |     Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -421,16 +420,16 @@ defmodule Cannery.Accounts do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> is_already_admin?(%User{role: :admin}) |       iex> already_admin?(%User{role: :admin}) | ||||||
|       true |       true | ||||||
|  |  | ||||||
|       iex> is_already_admin?(%User{}) |       iex> already_admin?(%User{}) | ||||||
|       false |       false | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec is_already_admin?(User.t() | nil) :: boolean() |   @spec already_admin?(User.t() | nil) :: boolean() | ||||||
|   def is_already_admin?(%User{role: :admin}), do: true |   def already_admin?(%User{role: :admin}), do: true | ||||||
|   def is_already_admin?(_invalid_user), do: false |   def already_admin?(_invalid_user), do: false | ||||||
|  |  | ||||||
|   ## Confirmation |   ## Confirmation | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,24 +5,19 @@ defmodule Cannery.Accounts.Invite do | |||||||
|   `:uses_left` is defined. |   `:uses_left` is defined. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|   alias Ecto.{Association, Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "invites" do |   schema "invites" do | ||||||
|     field :name, :string |     field :name, :string | ||||||
|     field :token, :string |     field :token, :string | ||||||
|     field :uses_left, :integer, default: nil |     field :uses_left, :integer, default: nil | ||||||
|     field :disabled_at, :naive_datetime |     field :disabled_at, :utc_datetime_usec | ||||||
|  |  | ||||||
|     belongs_to :created_by, User |     belongs_to :created_by, User | ||||||
|  |  | ||||||
|     has_many :users, User |     has_many :users, User | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
| @@ -30,12 +25,12 @@ defmodule Cannery.Accounts.Invite do | |||||||
|           name: String.t(), |           name: String.t(), | ||||||
|           token: token(), |           token: token(), | ||||||
|           uses_left: integer() | nil, |           uses_left: integer() | nil, | ||||||
|           disabled_at: NaiveDateTime.t(), |           disabled_at: DateTime.t(), | ||||||
|           created_by: User.t() | nil | Association.NotLoaded.t(), |           created_by: User.t() | nil | Association.NotLoaded.t(), | ||||||
|           created_by_id: User.id() | nil, |           created_by_id: User.id() | nil, | ||||||
|           users: [User.t()] | Association.NotLoaded.t(), |           users: [User.t()] | Association.NotLoaded.t(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_invite :: %__MODULE__{} |   @type new_invite :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|   | |||||||
| @@ -3,10 +3,8 @@ defmodule Cannery.Accounts.Invites do | |||||||
|   The Invites context. |   The Invites context. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |   use Cannery, :context | ||||||
|   alias Ecto.Multi |   alias Cannery.Accounts.Invite | ||||||
|   alias Cannery.Accounts.{Invite, User} |  | ||||||
|   alias Cannery.Repo |  | ||||||
|  |  | ||||||
|   @invite_token_length 20 |   @invite_token_length 20 | ||||||
|  |  | ||||||
| @@ -125,7 +123,7 @@ defmodule Cannery.Accounts.Invites do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do |   defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do | ||||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) |     now = DateTime.utc_now() | ||||||
|     invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) |     invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,11 +3,8 @@ defmodule Cannery.Accounts.User do | |||||||
|   A Cannery user |   A Cannery user | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |   alias Cannery.Accounts.Invite | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Ecto.{Association, Changeset, UUID} |  | ||||||
|   alias Cannery.Accounts.{Invite, User} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -20,13 +17,12 @@ defmodule Cannery.Accounts.User do | |||||||
|              :updated_at |              :updated_at | ||||||
|            ]} |            ]} | ||||||
|   @derive {Inspect, except: [:password]} |   @derive {Inspect, except: [:password]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "users" do |   schema "users" do | ||||||
|     field :email, :string |     field :email, :string | ||||||
|     field :password, :string, virtual: true |     field :password, :string, virtual: true | ||||||
|     field :hashed_password, :string |     field :hashed_password, :string | ||||||
|     field :confirmed_at, :naive_datetime |     field :current_password, :string, virtual: true, redact: true | ||||||
|  |     field :confirmed_at, :utc_datetime_usec | ||||||
|     field :role, Ecto.Enum, values: [:admin, :user], default: :user |     field :role, Ecto.Enum, values: [:admin, :user], default: :user | ||||||
|     field :locale, :string |     field :locale, :string | ||||||
|  |  | ||||||
| @@ -34,7 +30,7 @@ defmodule Cannery.Accounts.User do | |||||||
|  |  | ||||||
|     belongs_to :invite, Invite |     belongs_to :invite, Invite | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %User{ |   @type t :: %User{ | ||||||
| @@ -42,14 +38,14 @@ defmodule Cannery.Accounts.User do | |||||||
|           email: String.t(), |           email: String.t(), | ||||||
|           password: String.t(), |           password: String.t(), | ||||||
|           hashed_password: String.t(), |           hashed_password: String.t(), | ||||||
|           confirmed_at: NaiveDateTime.t(), |           confirmed_at: DateTime.t(), | ||||||
|           role: role(), |           role: role(), | ||||||
|           locale: String.t() | nil, |           locale: String.t() | nil, | ||||||
|           created_invites: [Invite.t()] | Association.NotLoaded.t(), |           created_invites: [Invite.t()] | Association.NotLoaded.t(), | ||||||
|           invite: Invite.t() | nil | Association.NotLoaded.t(), |           invite: Invite.t() | nil | Association.NotLoaded.t(), | ||||||
|           invite_id: Invite.id() | nil, |           invite_id: Invite.id() | nil, | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_user :: %User{} |   @type new_user :: %User{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
| @@ -141,7 +137,7 @@ defmodule Cannery.Accounts.User do | |||||||
|     |> cast(attrs, [:email]) |     |> cast(attrs, [:email]) | ||||||
|     |> validate_email() |     |> validate_email() | ||||||
|     |> case do |     |> case do | ||||||
|       %{changes: %{email: _}} = changeset -> changeset |       %{changes: %{email: _email}} = changeset -> changeset | ||||||
|       %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) |       %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| @@ -172,7 +168,7 @@ defmodule Cannery.Accounts.User do | |||||||
|   """ |   """ | ||||||
|   @spec confirm_changeset(t() | changeset()) :: changeset() |   @spec confirm_changeset(t() | changeset()) :: changeset() | ||||||
|   def confirm_changeset(user_or_changeset) do |   def confirm_changeset(user_or_changeset) do | ||||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) |     now = DateTime.utc_now() | ||||||
|     user_or_changeset |> change(confirmed_at: now) |     user_or_changeset |> change(confirmed_at: now) | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -198,6 +194,8 @@ defmodule Cannery.Accounts.User do | |||||||
|   """ |   """ | ||||||
|   @spec validate_current_password(changeset(), String.t()) :: changeset() |   @spec validate_current_password(changeset(), String.t()) :: changeset() | ||||||
|   def validate_current_password(changeset, password) do |   def validate_current_password(changeset, password) do | ||||||
|  |     changeset = cast(changeset, %{current_password: password}, [:current_password]) | ||||||
|  |  | ||||||
|     if valid_password?(changeset.data, password), |     if valid_password?(changeset.data, password), | ||||||
|       do: changeset, |       do: changeset, | ||||||
|       else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) |       else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) | ||||||
|   | |||||||
| @@ -3,10 +3,7 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|   Schema for a user's session token |   Schema for a user's session token | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Query |  | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|   alias Ecto.{Association, UUID} |  | ||||||
|  |  | ||||||
|   @hash_algorithm :sha256 |   @hash_algorithm :sha256 | ||||||
|   @rand_size 32 |   @rand_size 32 | ||||||
| @@ -18,8 +15,6 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|   @change_email_validity_in_days 7 |   @change_email_validity_in_days 7 | ||||||
|   @session_validity_in_days 60 |   @session_validity_in_days 60 | ||||||
|  |  | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "users_tokens" do |   schema "users_tokens" do | ||||||
|     field :token, :binary |     field :token, :binary | ||||||
|     field :context, :string |     field :context, :string | ||||||
| @@ -37,7 +32,7 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|           sent_to: String.t(), |           sent_to: String.t(), | ||||||
|           user: User.t() | Association.NotLoaded.t(), |           user: User.t() | Association.NotLoaded.t(), | ||||||
|           user_id: User.id() | nil, |           user_id: User.id() | nil, | ||||||
|           inserted_at: NaiveDateTime.t() |           inserted_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_user_token :: %__MODULE__{} |   @type new_user_token :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
| @@ -155,7 +150,7 @@ defmodule Cannery.Accounts.UserToken do | |||||||
|     from t in __MODULE__, where: t.user_id == ^user.id |     from t in __MODULE__, where: t.user_id == ^user.id | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def user_and_contexts_query(user, [_ | _] = contexts) do |   def user_and_contexts_query(user, [_first | _rest] = contexts) do | ||||||
|     from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts |     from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -3,43 +3,56 @@ defmodule Cannery.ActivityLog do | |||||||
|   The ActivityLog context. |   The ActivityLog context. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   import Ecto.Query, warn: false |   use Cannery, :context | ||||||
|   alias Cannery.Ammo.{Pack, Type} |   alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type} | ||||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo} |  | ||||||
|   alias Ecto.{Multi, Queryable} |   @type list_shot_records_option :: | ||||||
|  |           {:search, String.t() | nil} | ||||||
|  |           | {:class, Type.class() | :all | nil} | ||||||
|  |           | {:start_date, String.t() | nil} | ||||||
|  |           | {:end_date, String.t() | nil} | ||||||
|  |           | {:pack_id, Pack.id() | nil} | ||||||
|  |   @type list_shot_records_options :: [list_shot_records_option()] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the list of shot_records. |   Returns the list of shot_records. | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> list_shot_records(:all, %User{id: 123}) |       iex> list_shot_records(%User{id: 123}) | ||||||
|       [%ShotRecord{}, ...] |       [%ShotRecord{}, ...] | ||||||
|  |  | ||||||
|       iex> list_shot_records("cool", :all, %User{id: 123}) |       iex> list_shot_records(%User{id: 123}, search: "cool") | ||||||
|       [%ShotRecord{notes: "My cool shot record"}, ...] |       [%ShotRecord{notes: "My cool shot record"}, ...] | ||||||
|  |  | ||||||
|       iex> list_shot_records("cool", :rifle, %User{id: 123}) |       iex> list_shot_records(%User{id: 123}, search: "cool", class: :rifle) | ||||||
|       [%ShotRecord{notes: "Shot some rifle rounds"}, ...] |       [%ShotRecord{notes: "Shot some rifle rounds"}, ...] | ||||||
|  |  | ||||||
|  |       iex> list_shot_records(%User{id: 123}, pack_id: 456) | ||||||
|  |       [%ShotRecord{pack_id: 456}, ...] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_shot_records(Type.class() | :all, User.t()) :: [ShotRecord.t()] |   @spec list_shot_records(User.t()) :: [ShotRecord.t()] | ||||||
|   @spec list_shot_records(search :: nil | String.t(), Type.class() | :all, User.t()) :: |   @spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()] | ||||||
|           [ShotRecord.t()] |   def list_shot_records(%User{id: user_id}, opts \\ []) do | ||||||
|   def list_shot_records(search \\ nil, type, %{id: user_id}) do |     from(sr in ShotRecord, | ||||||
|     from(sg in ShotRecord, |       as: :sr, | ||||||
|       as: :sg, |       left_join: p in Pack, | ||||||
|       left_join: ag in Pack, |       as: :p, | ||||||
|       as: :ag, |       on: sr.pack_id == p.id, | ||||||
|       on: sg.pack_id == ag.id, |       on: p.user_id == ^user_id, | ||||||
|       left_join: at in Type, |       left_join: t in Type, | ||||||
|       as: :at, |       as: :t, | ||||||
|       on: ag.type_id == at.id, |       on: p.type_id == t.id, | ||||||
|       where: sg.user_id == ^user_id, |       on: t.user_id == ^user_id, | ||||||
|       distinct: sg.id |       where: sr.user_id == ^user_id, | ||||||
|  |       distinct: sr.id | ||||||
|     ) |     ) | ||||||
|     |> list_shot_records_search(search) |     |> list_shot_records_search(Keyword.get(opts, :search)) | ||||||
|     |> list_shot_records_filter_type(type) |     |> list_shot_records_class(Keyword.get(opts, :class)) | ||||||
|  |     |> list_shot_records_pack_id(Keyword.get(opts, :pack_id)) | ||||||
|  |     |> list_shot_records_start_date(Keyword.get(opts, :start_date)) | ||||||
|  |     |> list_shot_records_end_date(Keyword.get(opts, :end_date)) | ||||||
|     |> Repo.all() |     |> Repo.all() | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -52,45 +65,58 @@ defmodule Cannery.ActivityLog do | |||||||
|  |  | ||||||
|     query |     query | ||||||
|     |> where( |     |> where( | ||||||
|       [sg: sg, ag: ag, at: at], |       [sr: sr, p: p, t: t], | ||||||
|       fragment( |       fragment( | ||||||
|         "? @@ websearch_to_tsquery('english', ?)", |         "? @@ websearch_to_tsquery('english', ?)", | ||||||
|         sg.search, |         sr.search, | ||||||
|         ^trimmed_search |         ^trimmed_search | ||||||
|       ) or |       ) or | ||||||
|         fragment( |         fragment( | ||||||
|           "? @@ websearch_to_tsquery('english', ?)", |           "? @@ websearch_to_tsquery('english', ?)", | ||||||
|           ag.search, |           p.search, | ||||||
|           ^trimmed_search |           ^trimmed_search | ||||||
|         ) or |         ) or | ||||||
|         fragment( |         fragment( | ||||||
|           "? @@ websearch_to_tsquery('english', ?)", |           "? @@ websearch_to_tsquery('english', ?)", | ||||||
|           at.search, |           t.search, | ||||||
|           ^trimmed_search |           ^trimmed_search | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|     |> order_by([sg: sg], { |     |> order_by([sr: sr], { | ||||||
|       :desc, |       :desc, | ||||||
|       fragment( |       fragment( | ||||||
|         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", |         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||||
|         sg.search, |         sr.search, | ||||||
|         ^trimmed_search |         ^trimmed_search | ||||||
|       ) |       ) | ||||||
|     }) |     }) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @spec list_shot_records_filter_type(Queryable.t(), Type.class() | :all) :: |   @spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t() | ||||||
|           Queryable.t() |   defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun], | ||||||
|   defp list_shot_records_filter_type(query, :rifle), |     do: query |> where([t: t], t.class == ^class) | ||||||
|     do: query |> where([at: at], at.class == :rifle) |  | ||||||
|  |  | ||||||
|   defp list_shot_records_filter_type(query, :pistol), |   defp list_shot_records_class(query, _all), do: query | ||||||
|     do: query |> where([at: at], at.class == :pistol) |  | ||||||
|  |  | ||||||
|   defp list_shot_records_filter_type(query, :shotgun), |   @spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t() | ||||||
|     do: query |> where([at: at], at.class == :shotgun) |   defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(), | ||||||
|  |     do: query |> where([sr: sr], sr.pack_id == ^pack_id) | ||||||
|  |  | ||||||
|   defp list_shot_records_filter_type(query, _all), do: query |   defp list_shot_records_pack_id(query, _all), do: query | ||||||
|  |  | ||||||
|  |   @spec list_shot_records_start_date(Queryable.t(), String.t() | nil) :: Queryable.t() | ||||||
|  |   defp list_shot_records_start_date(query, start_date) when start_date |> is_binary() do | ||||||
|  |     query |> where([sr: sr], sr.date >= ^Date.from_iso8601!(start_date)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp list_shot_records_start_date(query, _all), do: query | ||||||
|  |  | ||||||
|  |   @spec list_shot_records_end_date(Queryable.t(), String.t() | nil) :: Queryable.t() | ||||||
|  |   defp list_shot_records_end_date(query, end_date) when end_date |> is_binary() do | ||||||
|  |     query |> where([sr: sr], sr.date <= ^Date.from_iso8601!(end_date)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp list_shot_records_end_date(query, _all), do: query | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns a count of shot records. |   Returns a count of shot records. | ||||||
| @@ -104,25 +130,13 @@ defmodule Cannery.ActivityLog do | |||||||
|   @spec get_shot_record_count!(User.t()) :: integer() |   @spec get_shot_record_count!(User.t()) :: integer() | ||||||
|   def get_shot_record_count!(%User{id: user_id}) do |   def get_shot_record_count!(%User{id: user_id}) do | ||||||
|     Repo.one( |     Repo.one( | ||||||
|       from sg in ShotRecord, |       from sr in ShotRecord, | ||||||
|         where: sg.user_id == ^user_id, |         where: sr.user_id == ^user_id, | ||||||
|         select: count(sg.id), |         select: count(sr.id), | ||||||
|         distinct: true |         distinct: true | ||||||
|     ) || 0 |     ) || 0 | ||||||
|   end |   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 |  | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets a single shot_record. |   Gets a single shot_record. | ||||||
|  |  | ||||||
| @@ -140,10 +154,10 @@ defmodule Cannery.ActivityLog do | |||||||
|   @spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t() |   @spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t() | ||||||
|   def get_shot_record!(id, %User{id: user_id}) do |   def get_shot_record!(id, %User{id: user_id}) do | ||||||
|     Repo.one!( |     Repo.one!( | ||||||
|       from sg in ShotRecord, |       from sr in ShotRecord, | ||||||
|         where: sg.id == ^id, |         where: sr.id == ^id, | ||||||
|         where: sg.user_id == ^user_id, |         where: sr.user_id == ^user_id, | ||||||
|         order_by: sg.date |         order_by: sr.date | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -172,9 +186,9 @@ defmodule Cannery.ActivityLog do | |||||||
|       fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} -> |       fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||||
|         pack = |         pack = | ||||||
|           Repo.one( |           Repo.one( | ||||||
|             from ag in Pack, |             from p in Pack, | ||||||
|               where: ag.id == ^pack_id, |               where: p.id == ^pack_id, | ||||||
|               where: ag.user_id == ^user_id |               where: p.user_id == ^user_id | ||||||
|           ) |           ) | ||||||
|  |  | ||||||
|         {:ok, pack} |         {:ok, pack} | ||||||
| @@ -221,7 +235,7 @@ defmodule Cannery.ActivityLog do | |||||||
|     |> Multi.run( |     |> Multi.run( | ||||||
|       :pack, |       :pack, | ||||||
|       fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} -> |       fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||||
|         {:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)} |         {:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)} | ||||||
|       end |       end | ||||||
|     ) |     ) | ||||||
|     |> Multi.update( |     |> Multi.update( | ||||||
| @@ -266,7 +280,7 @@ defmodule Cannery.ActivityLog do | |||||||
|     |> Multi.run( |     |> Multi.run( | ||||||
|       :pack, |       :pack, | ||||||
|       fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} -> |       fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||||
|         {:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)} |         {:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)} | ||||||
|       end |       end | ||||||
|     ) |     ) | ||||||
|     |> Multi.update( |     |> Multi.update( | ||||||
| @@ -287,36 +301,6 @@ defmodule Cannery.ActivityLog do | |||||||
|     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 """ |   @doc """ | ||||||
|   Returns the last entered shot record date for a pack |   Returns the last entered shot record date for a pack | ||||||
|   """ |   """ | ||||||
| @@ -337,15 +321,18 @@ defmodule Cannery.ActivityLog do | |||||||
|       |> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end) |       |> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end) | ||||||
|  |  | ||||||
|     Repo.all( |     Repo.all( | ||||||
|       from sg in ShotRecord, |       from sr in ShotRecord, | ||||||
|         where: sg.pack_id in ^pack_ids, |         where: sr.pack_id in ^pack_ids, | ||||||
|         where: sg.user_id == ^user_id, |         where: sr.user_id == ^user_id, | ||||||
|         group_by: sg.pack_id, |         group_by: sr.pack_id, | ||||||
|         select: {sg.pack_id, max(sg.date)} |         select: {sr.pack_id, max(sr.date)} | ||||||
|     ) |     ) | ||||||
|     |> Map.new() |     |> Map.new() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   @type get_used_count_option :: {:pack_id, Pack.id() | nil} | {:type_id, Type.id() | nil} | ||||||
|  |   @type get_used_count_options :: [get_used_count_option()] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets the total number of rounds shot for a type |   Gets the total number of rounds shot for a type | ||||||
|  |  | ||||||
| @@ -353,45 +340,116 @@ defmodule Cannery.ActivityLog do | |||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_used_count_for_type(123, %User{id: 123}) |       iex> get_used_count(%User{id: 123}, type_id: 123) | ||||||
|       35 |       35 | ||||||
|  |  | ||||||
|       iex> get_used_count_for_type(456, %User{id: 123}) |       iex> get_used_count(%User{id: 123}, pack_id: 456) | ||||||
|       ** (Ecto.NoResultsError) |       50 | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec get_used_count_for_type(Type.t(), User.t()) :: non_neg_integer() |   @spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer() | ||||||
|   def get_used_count_for_type(%Type{id: type_id} = type, user) do |   def get_used_count(%User{id: user_id}, opts) do | ||||||
|     [type] |     from(sr in ShotRecord, | ||||||
|     |> get_used_count_for_types(user) |       as: :sr, | ||||||
|     |> Map.get(type_id, 0) |       left_join: p in Pack, | ||||||
|  |       on: sr.pack_id == p.id, | ||||||
|  |       on: p.user_id == ^user_id, | ||||||
|  |       as: :p, | ||||||
|  |       where: sr.user_id == ^user_id, | ||||||
|  |       where: not (sr.count |> is_nil()), | ||||||
|  |       select: sum(sr.count), | ||||||
|  |       distinct: true | ||||||
|  |     ) | ||||||
|  |     |> get_used_count_type_id(Keyword.get(opts, :type_id)) | ||||||
|  |     |> get_used_count_pack_id(Keyword.get(opts, :pack_id)) | ||||||
|  |     |> Repo.one() || 0 | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   @spec get_used_count_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t() | ||||||
|  |   defp get_used_count_pack_id(query, pack_id) when pack_id |> is_binary() do | ||||||
|  |     query |> where([sr: sr], sr.pack_id == ^pack_id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_used_count_pack_id(query, _nil), do: query | ||||||
|  |  | ||||||
|  |   @spec get_used_count_type_id(Queryable.t(), Type.id() | nil) :: Queryable.t() | ||||||
|  |   defp get_used_count_type_id(query, type_id) when type_id |> is_binary() do | ||||||
|  |     query |> where([p: p], p.type_id == ^type_id) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_used_count_type_id(query, _nil), do: query | ||||||
|  |  | ||||||
|  |   @type get_grouped_used_counts_option :: | ||||||
|  |           {:packs, [Pack.t()] | nil} | ||||||
|  |           | {:types, [Type.t()] | nil} | ||||||
|  |           | {:group_by, :type_id | :pack_id} | ||||||
|  |   @type get_grouped_used_counts_options :: [get_grouped_used_counts_option()] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Gets the total number of rounds shot for multiple types |   Gets the total number of rounds shot for multiple types or packs | ||||||
|  |  | ||||||
|   ## Examples |   ## Examples | ||||||
|  |  | ||||||
|       iex> get_used_count_for_types(123, %User{id: 123}) |       iex> get_grouped_used_counts( | ||||||
|  |       ...>   %User{id: 123}, | ||||||
|  |       ...>   group_by: :type_id, | ||||||
|  |       ...>   types: [%Type{id: 456, user_id: 123}] | ||||||
|  |       ...> ) | ||||||
|       35 |       35 | ||||||
|  |  | ||||||
|   """ |       iex> get_grouped_used_counts( | ||||||
|   @spec get_used_count_for_types([Type.t()], User.t()) :: |       ...>   %User{id: 123}, | ||||||
|           %{optional(Type.id()) => non_neg_integer()} |       ...>   group_by: :pack_id, | ||||||
|   def get_used_count_for_types(types, %User{id: user_id}) do |       ...>   packs: [%Pack{id: 456, user_id: 123}] | ||||||
|     type_ids = |       ...> ) | ||||||
|       types |       22 | ||||||
|       |> Enum.map(fn %Type{id: type_id, user_id: ^user_id} -> type_id end) |  | ||||||
|  |  | ||||||
|     Repo.all( |   """ | ||||||
|       from ag in Pack, |   @spec get_grouped_used_counts(User.t(), get_grouped_used_counts_options()) :: | ||||||
|         left_join: sg in ShotRecord, |           %{optional(Type.id() | Pack.id()) => non_neg_integer()} | ||||||
|         on: ag.id == sg.pack_id, |   def get_grouped_used_counts(%User{id: user_id}, opts) do | ||||||
|         where: ag.type_id in ^type_ids, |     from(p in Pack, | ||||||
|         where: not (sg.count |> is_nil()), |       as: :p, | ||||||
|         group_by: ag.type_id, |       left_join: sr in ShotRecord, | ||||||
|         select: {ag.type_id, sum(sg.count)} |       on: p.id == sr.pack_id, | ||||||
|  |       on: p.user_id == ^user_id, | ||||||
|  |       as: :sr, | ||||||
|  |       where: sr.user_id == ^user_id, | ||||||
|  |       where: not (sr.count |> is_nil()) | ||||||
|     ) |     ) | ||||||
|  |     |> get_grouped_used_counts_group_by(Keyword.fetch!(opts, :group_by)) | ||||||
|  |     |> get_grouped_used_counts_types(Keyword.get(opts, :types)) | ||||||
|  |     |> get_grouped_used_counts_packs(Keyword.get(opts, :packs)) | ||||||
|  |     |> Repo.all() | ||||||
|     |> Map.new() |     |> Map.new() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   @spec get_grouped_used_counts_group_by(Queryable.t(), :type_id | :pack_id) :: Queryable.t() | ||||||
|  |   defp get_grouped_used_counts_group_by(query, :type_id) do | ||||||
|  |     query | ||||||
|  |     |> group_by([p: p], p.type_id) | ||||||
|  |     |> select([sr: sr, p: p], {p.type_id, sum(sr.count)}) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_grouped_used_counts_group_by(query, :pack_id) do | ||||||
|  |     query | ||||||
|  |     |> group_by([sr: sr], sr.pack_id) | ||||||
|  |     |> select([sr: sr], {sr.pack_id, sum(sr.count)}) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @spec get_grouped_used_counts_types(Queryable.t(), [Type.t()] | nil) :: Queryable.t() | ||||||
|  |   defp get_grouped_used_counts_types(query, types) when types |> is_list() do | ||||||
|  |     type_ids = types |> Enum.map(fn %Type{id: type_id} -> type_id end) | ||||||
|  |     query |> where([p: p], p.type_id in ^type_ids) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_grouped_used_counts_types(query, _nil), do: query | ||||||
|  |  | ||||||
|  |   @spec get_grouped_used_counts_packs(Queryable.t(), [Pack.t()] | nil) :: Queryable.t() | ||||||
|  |   defp get_grouped_used_counts_packs(query, packs) when packs |> is_list() do | ||||||
|  |     pack_ids = packs |> Enum.map(fn %Pack{id: pack_id} -> pack_id end) | ||||||
|  |     query |> where([p: p], p.id in ^pack_ids) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp get_grouped_used_counts_packs(query, _nil), do: query | ||||||
| end | end | ||||||
|   | |||||||
| @@ -3,11 +3,8 @@ defmodule Cannery.ActivityLog.ShotRecord do | |||||||
|   A shot record records a group of ammo shot during a range trip |   A shot record records a group of ammo shot during a range trip | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import CanneryWeb.Gettext |   alias Cannery.{Ammo, Ammo.Pack} | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.Pack} |  | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -27,7 +24,7 @@ defmodule Cannery.ActivityLog.ShotRecord do | |||||||
|     field :user_id, :binary_id |     field :user_id, :binary_id | ||||||
|     field :pack_id, :binary_id |     field :pack_id, :binary_id | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
| @@ -37,8 +34,8 @@ defmodule Cannery.ActivityLog.ShotRecord do | |||||||
|           date: Date.t() | nil, |           date: Date.t() | nil, | ||||||
|           pack_id: Pack.id(), |           pack_id: Pack.id(), | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_shot_record :: %__MODULE__{} |   @type new_shot_record :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,55 +6,46 @@ defmodule Cannery.Ammo.Pack do | |||||||
|   amount paid for that ammunition, or what condition it is in |   amount paid for that ammunition, or what condition it is in | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import CanneryWeb.Gettext |   alias Cannery.{Ammo.Type, Containers, Containers.Container} | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.Ammo.Type |  | ||||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container} |  | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
|              :id, |              :container_id, | ||||||
|              :count, |              :count, | ||||||
|  |              :id, | ||||||
|  |              :lot_number, | ||||||
|              :notes, |              :notes, | ||||||
|              :price_paid, |              :price_paid, | ||||||
|              :lot_number, |              :type_id | ||||||
|              :staged, |  | ||||||
|              :type_id, |  | ||||||
|              :container_id |  | ||||||
|            ]} |            ]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "packs" do |   schema "packs" do | ||||||
|     field :count, :integer |     field :count, :integer | ||||||
|  |     field :lot_number, :string | ||||||
|     field :notes, :string |     field :notes, :string | ||||||
|     field :price_paid, :float |     field :price_paid, :float | ||||||
|     field :staged, :boolean, default: false |  | ||||||
|     field :lot_number, :string |  | ||||||
|     field :purchased_on, :date |     field :purchased_on, :date | ||||||
|  |  | ||||||
|     belongs_to :type, Type |     belongs_to :type, Type | ||||||
|     field :container_id, :binary_id |     field :container_id, :binary_id | ||||||
|     field :user_id, :binary_id |     field :user_id, :binary_id | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |  | ||||||
|           count: integer, |           count: integer, | ||||||
|  |           id: id(), | ||||||
|  |           lot_number: String.t() | nil, | ||||||
|           notes: String.t() | nil, |           notes: String.t() | nil, | ||||||
|           price_paid: float() | nil, |           price_paid: float() | nil, | ||||||
|           staged: boolean(), |  | ||||||
|           lot_number: String.t() | nil, |  | ||||||
|           purchased_on: Date.t(), |           purchased_on: Date.t(), | ||||||
|           type: Type.t() | nil, |           type: Type.t() | nil, | ||||||
|           type_id: Type.id(), |           type_id: Type.id(), | ||||||
|           container_id: Container.id(), |           container_id: Container.id(), | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_pack :: %__MODULE__{} |   @type new_pack :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
| @@ -70,34 +61,55 @@ defmodule Cannery.Ammo.Pack do | |||||||
|         ) :: changeset() |         ) :: changeset() | ||||||
|   def create_changeset( |   def create_changeset( | ||||||
|         pack, |         pack, | ||||||
|         %Type{id: type_id}, |         type, | ||||||
|         %Container{id: container_id, user_id: user_id}, |         container, | ||||||
|         %User{id: user_id}, |         %User{id: user_id}, | ||||||
|         attrs |         attrs | ||||||
|       ) |       ) | ||||||
|       when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do |       when is_binary(user_id) do | ||||||
|     pack |     type_id = | ||||||
|     |> change(type_id: type_id) |       case type do | ||||||
|     |> change(user_id: user_id) |         %Type{id: type_id} when is_binary(type_id) -> | ||||||
|     |> change(container_id: container_id) |           type_id | ||||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number]) |  | ||||||
|     |> validate_number(:count, greater_than: 0) |         _invalid_type -> | ||||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) |           nil | ||||||
|     |> validate_length(:lot_number, max: 255) |       end | ||||||
|     |> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id]) |  | ||||||
|  |     container_id = | ||||||
|  |       case container do | ||||||
|  |         %Container{id: container_id, user_id: ^user_id} when is_binary(container_id) -> | ||||||
|  |           container_id | ||||||
|  |  | ||||||
|  |         _invalid_container -> | ||||||
|  |           nil | ||||||
|       end |       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 |     pack | ||||||
|     |> cast(attrs, [:type_id, :container_id]) |     |> change(type_id: type_id) | ||||||
|     |> validate_required([:type_id, :container_id]) |     |> change(container_id: container_id) | ||||||
|  |     |> change(user_id: user_id) | ||||||
|  |     |> cast(attrs, [ | ||||||
|  |       :count, | ||||||
|  |       :lot_number, | ||||||
|  |       :notes, | ||||||
|  |       :price_paid, | ||||||
|  |       :purchased_on | ||||||
|  |     ]) | ||||||
|  |     |> validate_required(:type_id, message: dgettext("errors", "Please select a valid type")) | ||||||
|  |     |> validate_required(:container_id, | ||||||
|  |       message: dgettext("errors", "Please select a valid container") | ||||||
|  |     ) | ||||||
|     |> validate_number(:count, greater_than: 0) |     |> validate_number(:count, greater_than: 0) | ||||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) |     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||||
|     |> validate_length(:lot_number, max: 255) |     |> validate_length(:lot_number, max: 255) | ||||||
|     |> add_error(:invalid, dgettext("errors", "Please select a type and container")) |     |> validate_required([ | ||||||
|  |       :container_id, | ||||||
|  |       :count, | ||||||
|  |       :purchased_on, | ||||||
|  |       :type_id, | ||||||
|  |       :user_id | ||||||
|  |     ]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc false |   @doc false | ||||||
| @@ -105,19 +117,22 @@ defmodule Cannery.Ammo.Pack do | |||||||
|   def update_changeset(pack, attrs, user) do |   def update_changeset(pack, attrs, user) do | ||||||
|     pack |     pack | ||||||
|     |> cast(attrs, [ |     |> cast(attrs, [ | ||||||
|  |       :container_id, | ||||||
|       :count, |       :count, | ||||||
|       :price_paid, |  | ||||||
|       :notes, |  | ||||||
|       :staged, |  | ||||||
|       :purchased_on, |  | ||||||
|       :lot_number, |       :lot_number, | ||||||
|       :container_id |       :notes, | ||||||
|  |       :price_paid, | ||||||
|  |       :purchased_on | ||||||
|     ]) |     ]) | ||||||
|     |> validate_number(:count, greater_than_or_equal_to: 0) |     |> validate_number(:count, greater_than_or_equal_to: 0) | ||||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) |     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||||
|     |> validate_container_id(user) |     |> validate_container_id(user) | ||||||
|     |> validate_length(:lot_number, max: 255) |     |> validate_length(:lot_number, max: 255) | ||||||
|     |> validate_required([:count, :staged, :purchased_on, :container_id]) |     |> validate_required([ | ||||||
|  |       :container_id, | ||||||
|  |       :count, | ||||||
|  |       :purchased_on | ||||||
|  |     ]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp validate_container_id(changeset, user) do |   defp validate_container_id(changeset, user) do | ||||||
| @@ -137,7 +152,7 @@ defmodule Cannery.Ammo.Pack do | |||||||
|   @spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset() |   @spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset() | ||||||
|   def range_changeset(pack, attrs) do |   def range_changeset(pack, attrs) do | ||||||
|     pack |     pack | ||||||
|     |> cast(attrs, [:count, :staged]) |     |> cast(attrs, [:count]) | ||||||
|     |> validate_required([:count, :staged]) |     |> validate_required([:count]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -5,11 +5,8 @@ defmodule Cannery.Ammo.Type do | |||||||
|   Contains statistical information about the ammunition. |   Contains statistical information about the ammunition. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|   alias Cannery.Ammo.Pack |   alias Cannery.Ammo.Pack | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -46,8 +43,6 @@ defmodule Cannery.Ammo.Type do | |||||||
|              :shot_charge_weight, |              :shot_charge_weight, | ||||||
|              :dram_equivalent |              :dram_equivalent | ||||||
|            ]} |            ]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "types" do |   schema "types" do | ||||||
|     field :name, :string |     field :name, :string | ||||||
|     field :desc, :string |     field :desc, :string | ||||||
| @@ -95,7 +90,7 @@ defmodule Cannery.Ammo.Type do | |||||||
|     field :user_id, :binary_id |     field :user_id, :binary_id | ||||||
|     has_many :packs, Pack |     has_many :packs, Pack | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
| @@ -134,8 +129,8 @@ defmodule Cannery.Ammo.Type do | |||||||
|           dram_equivalent: String.t() | nil, |           dram_equivalent: String.t() | nil, | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           packs: [Pack.t()] | nil, |           packs: [Pack.t()] | nil, | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_type :: %__MODULE__{} |   @type new_type :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ defmodule Cannery.Application do | |||||||
|       CanneryWeb.Telemetry, |       CanneryWeb.Telemetry, | ||||||
|       # Start the PubSub system |       # Start the PubSub system | ||||||
|       {Phoenix.PubSub, name: Cannery.PubSub}, |       {Phoenix.PubSub, name: Cannery.PubSub}, | ||||||
|  |       {DNSCluster, query: Application.get_env(:cannery, :dns_cluster_query) || :ignore}, | ||||||
|       # Start the Endpoint (http/https) |       # Start the Endpoint (http/https) | ||||||
|       CanneryWeb.Endpoint, |       CanneryWeb.Endpoint, | ||||||
|       # Add Oban |       # Add Oban | ||||||
|   | |||||||
| @@ -3,14 +3,17 @@ defmodule Cannery.Containers do | |||||||
|   The Containers context. |   The Containers context. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   import CanneryWeb.Gettext |   use Cannery, :context | ||||||
|   import Ecto.Query, warn: false |   alias Cannery.Ammo.Pack | ||||||
|   alias Cannery.{Accounts.User, Ammo.Pack, Repo} |  | ||||||
|   alias Cannery.Containers.{Container, ContainerTag, Tag} |   alias Cannery.Containers.{Container, ContainerTag, Tag} | ||||||
|   alias Ecto.Changeset |  | ||||||
|  |  | ||||||
|   @container_preloads [:tags] |   @container_preloads [:tags] | ||||||
|  |  | ||||||
|  |   @type list_containers_option :: | ||||||
|  |           {:search, String.t() | nil} | ||||||
|  |           | {:staged, boolean() | nil} | ||||||
|  |   @type list_containers_options :: [list_containers_option()] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the list of containers. |   Returns the list of containers. | ||||||
|  |  | ||||||
| @@ -19,30 +22,41 @@ defmodule Cannery.Containers do | |||||||
|       iex> list_containers(%User{id: 123}) |       iex> list_containers(%User{id: 123}) | ||||||
|       [%Container{}, ...] |       [%Container{}, ...] | ||||||
|  |  | ||||||
|       iex> list_containers("cool", %User{id: 123}) |       iex> list_containers(%User{id: 123}, | ||||||
|  |       ...>   search: "cool", | ||||||
|  |       ...>   staged: true | ||||||
|  |       ...> ) | ||||||
|       [%Container{name: "my cool container"}, ...] |       [%Container{name: "my cool container"}, ...] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_containers(User.t()) :: [Container.t()] |   @spec list_containers(User.t()) :: [Container.t()] | ||||||
|   @spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()] |   @spec list_containers(User.t(), list_containers_options()) :: [Container.t()] | ||||||
|   def list_containers(search \\ nil, %User{id: user_id}) do |   def list_containers(%User{id: user_id}, opts \\ []) do | ||||||
|     from(c in Container, |     from(c in Container, | ||||||
|       as: :c, |       as: :c, | ||||||
|       left_join: t in assoc(c, :tags), |       left_join: t in assoc(c, :tags), | ||||||
|  |       on: c.user_id == t.user_id, | ||||||
|       as: :t, |       as: :t, | ||||||
|       where: c.user_id == ^user_id, |       where: c.user_id == ^user_id, | ||||||
|       order_by: c.name, |  | ||||||
|       distinct: c.id, |       distinct: c.id, | ||||||
|       preload: ^@container_preloads |       preload: ^@container_preloads | ||||||
|     ) |     ) | ||||||
|     |> list_containers_search(search) |     |> list_containers_search(Keyword.get(opts, :search)) | ||||||
|  |     |> list_containers_staged(Keyword.get(opts, :staged)) | ||||||
|     |> Repo.all() |     |> Repo.all() | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp list_containers_search(query, nil), do: query |   @spec list_containers_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t() | ||||||
|   defp list_containers_search(query, ""), do: query |   defp list_containers_staged(query, staged) when staged |> is_boolean(), | ||||||
|  |     do: query |> where([c: c], c.staged == ^staged) | ||||||
|  |  | ||||||
|   defp list_containers_search(query, search) do |   defp list_containers_staged(query, _nil), do: query | ||||||
|  |  | ||||||
|  |   @spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t() | ||||||
|  |   defp list_containers_search(query, search) when search in ["", nil], | ||||||
|  |     do: query |> order_by([c: c], c.name) | ||||||
|  |  | ||||||
|  |   defp list_containers_search(query, search) when search |> is_binary() do | ||||||
|     trimmed_search = String.trim(search) |     trimmed_search = String.trim(search) | ||||||
|  |  | ||||||
|     query |     query | ||||||
| @@ -203,9 +217,9 @@ defmodule Cannery.Containers do | |||||||
|           {:ok, Container.t()} | {:error, Container.changeset()} |           {:ok, Container.t()} | {:error, Container.changeset()} | ||||||
|   def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do |   def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do | ||||||
|     Repo.one( |     Repo.one( | ||||||
|       from ag in Pack, |       from p in Pack, | ||||||
|         where: ag.container_id == ^container.id, |         where: p.container_id == ^container.id, | ||||||
|         select: count(ag.id) |         select: count(p.id) | ||||||
|     ) |     ) | ||||||
|     |> case do |     |> case do | ||||||
|       0 -> |       0 -> | ||||||
| @@ -289,6 +303,9 @@ defmodule Cannery.Containers do | |||||||
|  |  | ||||||
|   # Container Tags |   # Container Tags | ||||||
|  |  | ||||||
|  |   @type list_tags_option :: {:search, String.t() | nil} | ||||||
|  |   @type list_tags_options :: [list_tags_option()] | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Returns the list of tags. |   Returns the list of tags. | ||||||
|  |  | ||||||
| @@ -297,38 +314,42 @@ defmodule Cannery.Containers do | |||||||
|       iex> list_tags(%User{id: 123}) |       iex> list_tags(%User{id: 123}) | ||||||
|       [%Tag{}, ...] |       [%Tag{}, ...] | ||||||
|  |  | ||||||
|       iex> list_tags("cool", %User{id: 123}) |       iex> list_tags(%User{id: 123}, search: "cool") | ||||||
|       [%Tag{name: "my cool tag"}, ...] |       [%Tag{name: "my cool tag"}, ...] | ||||||
|  |  | ||||||
|   """ |   """ | ||||||
|   @spec list_tags(User.t()) :: [Tag.t()] |   @spec list_tags(User.t()) :: [Tag.t()] | ||||||
|   @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] |   @spec list_tags(User.t(), list_tags_options()) :: [Tag.t()] | ||||||
|   def list_tags(search \\ nil, user) |   def list_tags(%User{id: user_id}, opts \\ []) do | ||||||
|  |     from(t in Tag, as: :t, where: t.user_id == ^user_id) | ||||||
|  |     |> list_tags_search(Keyword.get(opts, :search)) | ||||||
|  |     |> Repo.all() | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", |   @spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t() | ||||||
|     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) |   defp list_tags_search(query, search) when search in ["", nil], | ||||||
|  |     do: query |> order_by([t: t], t.name) | ||||||
|  |  | ||||||
|   def list_tags(search, %{id: user_id}) when search |> is_binary() do |   defp list_tags_search(query, search) when search |> is_binary() do | ||||||
|     trimmed_search = String.trim(search) |     trimmed_search = String.trim(search) | ||||||
|  |  | ||||||
|     Repo.all( |     query | ||||||
|       from t in Tag, |     |> where( | ||||||
|         where: t.user_id == ^user_id, |       [t: t], | ||||||
|         where: |  | ||||||
|       fragment( |       fragment( | ||||||
|         "? @@ websearch_to_tsquery('english', ?)", |         "? @@ websearch_to_tsquery('english', ?)", | ||||||
|         t.search, |         t.search, | ||||||
|         ^trimmed_search |         ^trimmed_search | ||||||
|           ), |       ) | ||||||
|         order_by: { |     ) | ||||||
|  |     |> order_by([t: t], { | ||||||
|       :desc, |       :desc, | ||||||
|       fragment( |       fragment( | ||||||
|         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", |         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||||
|         t.search, |         t.search, | ||||||
|         ^trimmed_search |         ^trimmed_search | ||||||
|       ) |       ) | ||||||
|         } |     }) | ||||||
|     ) |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   | |||||||
| @@ -3,45 +3,44 @@ defmodule Cannery.Containers.Container do | |||||||
|   A container that holds ammunition and belongs to a user. |   A container that holds ammunition and belongs to a user. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |   alias Cannery.{Containers.ContainerTag, Containers.Tag} | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|   alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
|              :id, |  | ||||||
|              :name, |  | ||||||
|              :desc, |              :desc, | ||||||
|  |              :id, | ||||||
|              :location, |              :location, | ||||||
|              :type, |              :name, | ||||||
|              :tags |              :staged, | ||||||
|  |              :tags, | ||||||
|  |              :type | ||||||
|            ]} |            ]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "containers" do |   schema "containers" do | ||||||
|     field :name, :string |  | ||||||
|     field :desc, :string |     field :desc, :string | ||||||
|     field :location, :string |     field :location, :string | ||||||
|  |     field :name, :string | ||||||
|  |     field :staged, :boolean, default: false | ||||||
|     field :type, :string |     field :type, :string | ||||||
|  |  | ||||||
|     field :user_id, :binary_id |     field :user_id, :binary_id | ||||||
|  |  | ||||||
|     many_to_many :tags, Tag, join_through: ContainerTag |     many_to_many :tags, Tag, join_through: ContainerTag | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
|           id: id(), |  | ||||||
|           name: String.t(), |  | ||||||
|           desc: String.t(), |           desc: String.t(), | ||||||
|  |           id: id(), | ||||||
|           location: String.t(), |           location: String.t(), | ||||||
|  |           name: String.t(), | ||||||
|  |           staged: boolean(), | ||||||
|           type: String.t(), |           type: String.t(), | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           tags: [Tag.t()] | nil, |           tags: [Tag.t()] | nil, | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_container :: %__MODULE__{} |   @type new_container :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
| @@ -52,19 +51,40 @@ defmodule Cannery.Containers.Container do | |||||||
|   def create_changeset(container, %User{id: user_id}, attrs) do |   def create_changeset(container, %User{id: user_id}, attrs) do | ||||||
|     container |     container | ||||||
|     |> change(user_id: user_id) |     |> change(user_id: user_id) | ||||||
|     |> cast(attrs, [:name, :desc, :type, :location]) |     |> cast(attrs, [ | ||||||
|  |       :desc, | ||||||
|  |       :location, | ||||||
|  |       :name, | ||||||
|  |       :staged, | ||||||
|  |       :type | ||||||
|  |     ]) | ||||||
|     |> validate_length(:name, max: 255) |     |> validate_length(:name, max: 255) | ||||||
|     |> validate_length(:type, max: 255) |     |> validate_length(:type, max: 255) | ||||||
|     |> validate_required([:name, :type, :user_id]) |     |> validate_required([ | ||||||
|  |       :name, | ||||||
|  |       :staged, | ||||||
|  |       :type, | ||||||
|  |       :user_id | ||||||
|  |     ]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @doc false |   @doc false | ||||||
|   @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() |   @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() | ||||||
|   def update_changeset(container, attrs) do |   def update_changeset(container, attrs) do | ||||||
|     container |     container | ||||||
|     |> cast(attrs, [:name, :desc, :type, :location]) |     |> cast(attrs, [ | ||||||
|  |       :desc, | ||||||
|  |       :location, | ||||||
|  |       :name, | ||||||
|  |       :staged, | ||||||
|  |       :type | ||||||
|  |     ]) | ||||||
|     |> validate_length(:name, max: 255) |     |> validate_length(:name, max: 255) | ||||||
|     |> validate_length(:type, max: 255) |     |> validate_length(:type, max: 255) | ||||||
|     |> validate_required([:name, :type]) |     |> validate_required([ | ||||||
|  |       :name, | ||||||
|  |       :staged, | ||||||
|  |       :type | ||||||
|  |     ]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -4,18 +4,14 @@ defmodule Cannery.Containers.ContainerTag do | |||||||
|   Cannery.Containers.Tag. |   Cannery.Containers.Tag. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.Containers.{Container, Tag} |   alias Cannery.Containers.{Container, Tag} | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "container_tags" do |   schema "container_tags" do | ||||||
|     belongs_to :container, Container |     belongs_to :container, Container | ||||||
|     belongs_to :tag, Tag |     belongs_to :tag, Tag | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
| @@ -24,8 +20,8 @@ defmodule Cannery.Containers.ContainerTag do | |||||||
|           container_id: Container.id(), |           container_id: Container.id(), | ||||||
|           tag: Tag.t(), |           tag: Tag.t(), | ||||||
|           tag_id: Tag.id(), |           tag_id: Tag.id(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_container_tag :: %__MODULE__{} |   @type new_container_tag :: %__MODULE__{} | ||||||
|   @type id :: UUID.t() |   @type id :: UUID.t() | ||||||
|   | |||||||
| @@ -4,10 +4,7 @@ defmodule Cannery.Containers.Tag do | |||||||
|   text and bg colors. |   text and bg colors. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Ecto.Schema |   use Cannery, :schema | ||||||
|   import Ecto.Changeset |  | ||||||
|   alias Cannery.Accounts.User |  | ||||||
|   alias Ecto.{Changeset, UUID} |  | ||||||
|  |  | ||||||
|   @derive {Jason.Encoder, |   @derive {Jason.Encoder, | ||||||
|            only: [ |            only: [ | ||||||
| @@ -16,8 +13,6 @@ defmodule Cannery.Containers.Tag do | |||||||
|              :bg_color, |              :bg_color, | ||||||
|              :text_color |              :text_color | ||||||
|            ]} |            ]} | ||||||
|   @primary_key {:id, :binary_id, autogenerate: true} |  | ||||||
|   @foreign_key_type :binary_id |  | ||||||
|   schema "tags" do |   schema "tags" do | ||||||
|     field :name, :string |     field :name, :string | ||||||
|     field :bg_color, :string |     field :bg_color, :string | ||||||
| @@ -25,7 +20,7 @@ defmodule Cannery.Containers.Tag do | |||||||
|  |  | ||||||
|     field :user_id, :binary_id |     field :user_id, :binary_id | ||||||
|  |  | ||||||
|     timestamps() |     timestamps(type: :utc_datetime_usec) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   @type t :: %__MODULE__{ |   @type t :: %__MODULE__{ | ||||||
| @@ -34,8 +29,8 @@ defmodule Cannery.Containers.Tag do | |||||||
|           bg_color: String.t(), |           bg_color: String.t(), | ||||||
|           text_color: String.t(), |           text_color: String.t(), | ||||||
|           user_id: User.id(), |           user_id: User.id(), | ||||||
|           inserted_at: NaiveDateTime.t(), |           inserted_at: DateTime.t(), | ||||||
|           updated_at: NaiveDateTime.t() |           updated_at: DateTime.t() | ||||||
|         } |         } | ||||||
|   @type new_tag() :: %__MODULE__{} |   @type new_tag() :: %__MODULE__{} | ||||||
|   @type id() :: UUID.t() |   @type id() :: UUID.t() | ||||||
|   | |||||||
| @@ -7,8 +7,8 @@ defmodule Cannery.Email do | |||||||
|   `lib/cannery_web/components/layouts/email_text.txt.eex` for text emails. |   `lib/cannery_web/components/layouts/email_text.txt.eex` for text emails. | ||||||
|   """ |   """ | ||||||
| 
 | 
 | ||||||
|  |   use Gettext, backend: CanneryWeb.Gettext | ||||||
|   import Swoosh.Email |   import Swoosh.Email | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   import Phoenix.Template |   import Phoenix.Template | ||||||
|   alias Cannery.Accounts.User |   alias Cannery.Accounts.User | ||||||
|   alias CanneryWeb.{EmailHTML, Layouts} |   alias CanneryWeb.{EmailHTML, Layouts} | ||||||
| @@ -14,7 +14,7 @@ defmodule Cannery.Logger do | |||||||
|       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) |       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||||
|       |> pretty_encode() |       |> pretty_encode() | ||||||
|  |  | ||||||
|     Logger.error("#{meta.reason}: #{data}") |     Logger.error("Oban exception: #{data}") | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def handle_event([:oban, :job, :start], measure, meta, _config) do |   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ defmodule CanneryWeb do | |||||||
|   those modules here. |   those modules here. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) |   def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts) | ||||||
|  |  | ||||||
|   def router do |   def router do | ||||||
|     quote do |     quote do | ||||||
| @@ -42,9 +42,10 @@ defmodule CanneryWeb do | |||||||
|         formats: [:html, :json], |         formats: [:html, :json], | ||||||
|         layouts: [html: CanneryWeb.Layouts] |         layouts: [html: CanneryWeb.Layouts] | ||||||
|  |  | ||||||
|  |       use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |  | ||||||
|       # 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 CanneryWeb.Gettext |  | ||||||
|  |  | ||||||
|       unquote(verified_routes()) |       unquote(verified_routes()) | ||||||
|     end |     end | ||||||
| @@ -69,6 +70,7 @@ defmodule CanneryWeb do | |||||||
|  |  | ||||||
|   def html do |   def html do | ||||||
|     quote do |     quote do | ||||||
|  |       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||||
|       use Phoenix.Component |       use Phoenix.Component | ||||||
|  |  | ||||||
|       # Import convenience functions from controllers |       # Import convenience functions from controllers | ||||||
| @@ -82,12 +84,10 @@ defmodule CanneryWeb do | |||||||
|  |  | ||||||
|   defp html_helpers do |   defp html_helpers do | ||||||
|     quote do |     quote do | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       use PhoenixHTMLHelpers | ||||||
|       use Phoenix.HTML |       use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |       import Phoenix.{Component, HTML, HTML.Form} | ||||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse |       import CanneryWeb.{ErrorHelpers, CoreComponents, HTMLHelpers} | ||||||
|       import Phoenix.Component |  | ||||||
|       import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers} |  | ||||||
|  |  | ||||||
|       # Shortcut for generating JS commands |       # Shortcut for generating JS commands | ||||||
|       alias Phoenix.LiveView.JS |       alias Phoenix.LiveView.JS | ||||||
|   | |||||||
| @@ -33,7 +33,9 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do | |||||||
|       ) do |       ) do | ||||||
|     params = shot_record_params |> process_params(pack) |     params = shot_record_params |> process_params(pack) | ||||||
|  |  | ||||||
|     changeset = %ShotRecord{} |> ShotRecord.create_changeset(current_user, pack, params) |     changeset = | ||||||
|  |       %ShotRecord{} | ||||||
|  |       |> ShotRecord.create_changeset(current_user, pack, params) | ||||||
|  |  | ||||||
|     changeset = |     changeset = | ||||||
|       case changeset |> Changeset.apply_action(:validate) do |       case changeset |> Changeset.apply_action(:validate) do | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div> | <div> | ||||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> |   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||||
|     <%= gettext("Record shots") %> |     {gettext("Record shots")} | ||||||
|   </h2> |   </h2> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -13,50 +13,50 @@ | |||||||
|     phx-submit="save" |     phx-submit="save" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       :if={@changeset.action && not @changeset.valid?()} |       :if={@changeset.action && not @changeset.valid?} | ||||||
|       class="invalid-feedback col-span-3 text-center" |       class="invalid-feedback col-span-3 text-center" | ||||||
|     > |     > | ||||||
|       <%= changeset_errors(@changeset) %> |       {changeset_errors(@changeset)} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= 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: @pack.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-record-form_ammo_left")} |       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")} | ||||||
|     > |     > | ||||||
|       <%= gettext("Used up!") %> |       {gettext("Used up!")} | ||||||
|     </button> |     </button> | ||||||
|     <%= error_tag(f, :ammo_left, "col-span-3") %> |     {error_tag(f, :ammo_left, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= 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", | ||||||
|  |       id: "add-shot-record-form-notes", | ||||||
|       maxlength: 255, |       maxlength: 255, | ||||||
|       placeholder: gettext("Really great weather"), |       phx_debounce: 300, | ||||||
|       phx_hook: "MaintainAttrs", |       phx_update: "ignore", | ||||||
|       phx_update: "ignore" |       placeholder: gettext("Really great weather") | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :notes, "col-span-3") %> |     {error_tag(f, :notes, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :date, gettext("Date"), 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", | ||||||
|       value: Date.utc_today() |       value: Date.utc_today() | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :notes, "col-span-3") %> |     {error_tag(f, :notes, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Save"), |     {submit(dgettext("actions", "Save"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3", |       class: "mx-auto btn btn-primary col-span-3", | ||||||
|       phx_disable_with: dgettext("prompts", "Saving...") |       phx_disable_with: dgettext("prompts", "Saving...") | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|   """ |   """ | ||||||
|   use CanneryWeb, :live_component |   use CanneryWeb, :live_component | ||||||
|   alias Cannery.{Accounts.User, Ammo, Containers.Container} |   alias Cannery.{Accounts.User, Ammo, Containers.Container} | ||||||
|  |   alias CanneryWeb.Components.TableComponent | ||||||
|   alias Ecto.UUID |   alias Ecto.UUID | ||||||
|   alias Phoenix.LiveView.{Rendered, Socket} |   alias Phoenix.LiveView.{Rendered, Socket} | ||||||
|  |  | ||||||
| @@ -13,6 +14,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|             required(:id) => UUID.t(), |             required(:id) => UUID.t(), | ||||||
|             required(:current_user) => User.t(), |             required(:current_user) => User.t(), | ||||||
|             optional(:containers) => [Container.t()], |             optional(:containers) => [Container.t()], | ||||||
|  |             optional(:range) => Rendered.t(), | ||||||
|             optional(:tag_actions) => Rendered.t(), |             optional(:tag_actions) => Rendered.t(), | ||||||
|             optional(:actions) => Rendered.t(), |             optional(:actions) => Rendered.t(), | ||||||
|             optional(any()) => any() |             optional(any()) => any() | ||||||
| @@ -23,6 +25,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|     socket = |     socket = | ||||||
|       socket |       socket | ||||||
|       |> assign(assigns) |       |> assign(assigns) | ||||||
|  |       |> assign_new(:range, fn -> [] end) | ||||||
|       |> assign_new(:tag_actions, fn -> [] end) |       |> assign_new(:tag_actions, fn -> [] end) | ||||||
|       |> assign_new(:actions, fn -> [] end) |       |> assign_new(:actions, fn -> [] end) | ||||||
|       |> display_containers() |       |> display_containers() | ||||||
| @@ -35,6 +38,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|            assigns: %{ |            assigns: %{ | ||||||
|              containers: containers, |              containers: containers, | ||||||
|              current_user: current_user, |              current_user: current_user, | ||||||
|  |              range: range, | ||||||
|              tag_actions: tag_actions, |              tag_actions: tag_actions, | ||||||
|              actions: actions |              actions: actions | ||||||
|            } |            } | ||||||
| @@ -62,17 +66,34 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|       end) |       end) | ||||||
|       |> Enum.concat([ |       |> Enum.concat([ | ||||||
|         %{label: gettext("Packs"), key: :packs, type: :integer}, |         %{label: gettext("Packs"), key: :packs, type: :integer}, | ||||||
|         %{label: gettext("Rounds"), key: :rounds, type: :integer}, |         %{label: gettext("Rounds"), key: :rounds, type: :integer} | ||||||
|  |       ]) | ||||||
|  |       |> Enum.concat( | ||||||
|  |         [ | ||||||
|           %{label: gettext("Tags"), key: :tags, type: :tags}, |           %{label: gettext("Tags"), key: :tags, type: :tags}, | ||||||
|           %{label: gettext("Actions"), key: :actions, sortable: false, type: :actions} |           %{label: gettext("Actions"), key: :actions, sortable: false, type: :actions} | ||||||
|       ]) |         ] | ||||||
|  |         |> TableComponent.maybe_compose_columns( | ||||||
|  |           %{label: gettext("Range"), key: :range}, | ||||||
|  |           range != [] | ||||||
|  |         ) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|     extra_data = %{ |     extra_data = %{ | ||||||
|       current_user: current_user, |       current_user: current_user, | ||||||
|  |       range: range, | ||||||
|       tag_actions: tag_actions, |       tag_actions: tag_actions, | ||||||
|       actions: actions, |       actions: actions, | ||||||
|       pack_count: Ammo.get_packs_count_for_containers(containers, current_user), |       pack_count: | ||||||
|       round_count: Ammo.get_round_count_for_containers(containers, current_user) |         Ammo.get_grouped_packs_count(current_user, | ||||||
|  |           containers: containers, | ||||||
|  |           group_by: :container_id | ||||||
|  |         ), | ||||||
|  |       round_count: | ||||||
|  |         Ammo.get_grouped_round_count(current_user, | ||||||
|  |           containers: containers, | ||||||
|  |           group_by: :container_id | ||||||
|  |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     rows = |     rows = | ||||||
| @@ -114,7 +135,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|      ~H""" |      ~H""" | ||||||
|      <div class="flex flex-wrap justify-center items-center"> |      <div class="flex flex-wrap justify-center items-center"> | ||||||
|        <.link navigate={~p"/container/#{@id}"} class="link"> |        <.link navigate={~p"/container/#{@id}"} class="link"> | ||||||
|          <%= @name %> |          {@name} | ||||||
|        </.link> |        </.link> | ||||||
|      </div> |      </div> | ||||||
|      """} |      """} | ||||||
| @@ -128,6 +149,15 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|     round_count |> Map.get(container_id, 0) |     round_count |> Map.get(container_id, 0) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   defp get_value_for_key(:range, %{staged: staged} = container, %{range: range}) do | ||||||
|  |     assigns = %{range: range, container: container} | ||||||
|  |  | ||||||
|  |     {staged, | ||||||
|  |      ~H""" | ||||||
|  |      {render_slot(@range, @container)} | ||||||
|  |      """} | ||||||
|  |   end | ||||||
|  |  | ||||||
|   defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do |   defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do | ||||||
|     assigns = %{tag_actions: tag_actions, container: container} |     assigns = %{tag_actions: tag_actions, container: container} | ||||||
|  |  | ||||||
| @@ -142,7 +172,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|      <div class="flex flex-wrap justify-center items-center"> |      <div class="flex flex-wrap justify-center items-center"> | ||||||
|        <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> |        <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||||
|  |  | ||||||
|        <%= render_slot(@tag_actions, @container) %> |        {render_slot(@tag_actions, @container)} | ||||||
|      </div> |      </div> | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
| @@ -151,7 +181,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | |||||||
|     assigns = %{actions: actions, container: container} |     assigns = %{actions: actions, container: container} | ||||||
|  |  | ||||||
|     ~H""" |     ~H""" | ||||||
|     <%= render_slot(@actions, @container) %> |     {render_slot(@actions, @container)} | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,7 +4,8 @@ defmodule CanneryWeb.CoreComponents do | |||||||
|   """ |   """ | ||||||
|   use Phoenix.Component |   use Phoenix.Component | ||||||
|   use CanneryWeb, :verified_routes |   use CanneryWeb, :verified_routes | ||||||
|   import CanneryWeb.{Gettext, HTMLHelpers} |   use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |   import CanneryWeb.HTMLHelpers | ||||||
|   alias Cannery.{Accounts, Accounts.Invite, Accounts.User} |   alias Cannery.{Accounts, Accounts.Invite, Accounts.User} | ||||||
|   alias Cannery.{Ammo, Ammo.Pack} |   alias Cannery.{Ammo, Ammo.Pack} | ||||||
|   alias Cannery.{Containers.Container, Containers.Tag} |   alias Cannery.{Containers.Container, Containers.Tag} | ||||||
| @@ -135,14 +136,26 @@ defmodule CanneryWeb.CoreComponents do | |||||||
|   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" |   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" | ||||||
|  |  | ||||||
|   @doc """ |   @doc """ | ||||||
|   Phoenix.Component for a <time> element that renders the naivedatetime in the |   Phoenix.Component for a <time> element that renders the DateTime in the | ||||||
|   user's local timezone |   user's local timezone | ||||||
|   """ |   """ | ||||||
|   def datetime(assigns) |   def datetime(assigns) | ||||||
|  |  | ||||||
|   @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() |   attr :name, :string, required: true | ||||||
|   defp cast_datetime(%NaiveDateTime{} = datetime) do |  | ||||||
|     datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) |   attr :start_date, :string, | ||||||
|  |     default: Date.utc_today() |> Date.shift(year: -1) |> Date.to_iso8601() | ||||||
|  |  | ||||||
|  |   attr :end_date, :string, default: Date.utc_today() |> Date.to_iso8601() | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Phoenix.Component for an element that generates date fields for a range | ||||||
|  |   """ | ||||||
|  |   def date_range(assigns) | ||||||
|  |  | ||||||
|  |   @spec cast_datetime(DateTime.t() | nil) :: String.t() | ||||||
|  |   defp cast_datetime(%DateTime{} = datetime) do | ||||||
|  |     datetime |> DateTime.to_iso8601(:extended) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp cast_datetime(_datetime), do: "" |   defp cast_datetime(_datetime), do: "" | ||||||
|   | |||||||
| @@ -2,40 +2,40 @@ | |||||||
|   id={"container-#{@container.id}"} |   id={"container-#{@container.id}"} | ||||||
|   class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 |   class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 | ||||||
|     flex flex-col justify-around items-center space-y-4 |     flex flex-col justify-around items-center space-y-4 | ||||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md |     border border-zinc-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|     transition-all duration-300 ease-in-out" |     transition-all duration-300 ease-in-out" | ||||||
| > | > | ||||||
|   <.link navigate={~p"/container/#{@container}"} class="link"> |   <.link navigate={~p"/container/#{@container}"} class="link"> | ||||||
|     <h1 class="px-4 py-2 rounded-lg title text-xl"> |     <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||||
|       <%= @container.name %> |       {@container.name} | ||||||
|     </h1> |     </h1> | ||||||
|   </.link> |   </.link> | ||||||
|  |  | ||||||
|   <div class="flex flex-col justify-center items-center space-y-2"> |   <div class="flex flex-col justify-center items-center space-y-2"> | ||||||
|     <span :if={@container.desc} class="rounded-lg title text-lg"> |     <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Description:") %> |       {gettext("Description:")} | ||||||
|       <%= @container.desc %> |       {@container.desc} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span class="rounded-lg title text-lg"> |     <span class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Type:") %> |       {gettext("Type:")} | ||||||
|       <%= @container.type %> |       {@container.type} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@container.location} class="rounded-lg title text-lg"> |     <span :if={@container.location} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Location:") %> |       {gettext("Location:")} | ||||||
|       <%= @container.location %> |       {@container.location} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <%= if @container |> Ammo.get_packs_count_for_container!(@current_user) != 0 do %> |     <%= if Ammo.get_packs_count(@current_user, container_id: @container.id) != 0 do %> | ||||||
|       <span class="rounded-lg title text-lg"> |       <span class="rounded-lg title text-lg"> | ||||||
|         <%= gettext("Packs:") %> |         {gettext("Packs:")} | ||||||
|         <%= @container |> Ammo.get_packs_count_for_container!(@current_user) %> |         {Ammo.get_packs_count(@current_user, container_id: @container.id)} | ||||||
|       </span> |       </span> | ||||||
|  |  | ||||||
|       <span class="rounded-lg title text-lg"> |       <span class="rounded-lg title text-lg"> | ||||||
|         <%= gettext("Rounds:") %> |         {gettext("Rounds:")} | ||||||
|         <%= @container |> Ammo.get_round_count_for_container!(@current_user) %> |         {Ammo.get_round_count(@current_user, container_id: @container.id)} | ||||||
|       </span> |       </span> | ||||||
|     <% end %> |     <% end %> | ||||||
|  |  | ||||||
| @@ -45,7 +45,7 @@ | |||||||
|     > |     > | ||||||
|       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> |       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||||
|  |  | ||||||
|       <%= if @tag_actions, do: render_slot(@tag_actions) %> |       {if @tag_actions, do: render_slot(@tag_actions)} | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
| @@ -53,6 +53,6 @@ | |||||||
|     :if={assigns |> Map.has_key?(:inner_block)} |     :if={assigns |> Map.has_key?(:inner_block)} | ||||||
|     class="flex space-x-4 justify-center items-center" |     class="flex space-x-4 justify-center items-center" | ||||||
|   > |   > | ||||||
|     <%= render_slot(@inner_block) %> |     {render_slot(@inner_block)} | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | ||||||
|   <%= Date.to_iso8601(@date, :extended) %> |   {Date.to_iso8601(@date, :extended)} | ||||||
| </time> | </time> | ||||||
|   | |||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | <div class="flex items-center mx-4 my-2 space-x-1"> | ||||||
|  |   <input | ||||||
|  |     class="w-40 text-center input input-primary" | ||||||
|  |     name={"#{@name}_start"} | ||||||
|  |     type="date" | ||||||
|  |     value={@start_date} | ||||||
|  |   /> | ||||||
|  |   <span>—</span> | ||||||
|  |   <input | ||||||
|  |     class="w-40 text-center input input-primary" | ||||||
|  |     name={"#{@name}_end"} | ||||||
|  |     type="date" | ||||||
|  |     value={@end_date} | ||||||
|  |   /> | ||||||
|  | </div> | ||||||
| @@ -1,3 +1,3 @@ | |||||||
| <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | ||||||
|   <%= cast_datetime(@datetime) %> |   {cast_datetime(@datetime)} | ||||||
| </time> | </time> | ||||||
|   | |||||||
| @@ -1,24 +1,24 @@ | |||||||
| <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||||
|   border border-gray-400 rounded-lg shadow-lg hover:shadow-md |   border border-zinc-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|   transition-all duration-300 ease-in-out"> |   transition-all duration-300 ease-in-out"> | ||||||
|   <h1 class="title text-xl"> |   <h1 class="title text-xl"> | ||||||
|     <%= @invite.name %> |     {@invite.name} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <%= if @invite.disabled_at |> is_nil() do %> |   <%= if @invite.disabled_at |> is_nil() do %> | ||||||
|     <h2 class="title text-md"> |     <h2 class="title text-md"> | ||||||
|       <%= if @invite.uses_left do %> |       <%= if @invite.uses_left do %> | ||||||
|         <%= gettext( |         {gettext( | ||||||
|           "Uses Left: %{uses_left_count}", |           "Uses Left: %{uses_left_count}", | ||||||
|           uses_left_count: @invite.uses_left |           uses_left_count: @invite.uses_left | ||||||
|         ) %> |         )} | ||||||
|       <% else %> |       <% else %> | ||||||
|         <%= gettext("Uses Left: Unlimited") %> |         {gettext("Uses Left: Unlimited")} | ||||||
|       <% end %> |       <% end %> | ||||||
|     </h2> |     </h2> | ||||||
|   <% else %> |   <% else %> | ||||||
|     <h2 class="title text-md"> |     <h2 class="title text-md"> | ||||||
|       <%= gettext("Invite Disabled") %> |       {gettext("Invite Disabled")} | ||||||
|     </h2> |     </h2> | ||||||
|   <% end %> |   <% end %> | ||||||
|  |  | ||||||
| @@ -28,19 +28,19 @@ | |||||||
|   /> |   /> | ||||||
|  |  | ||||||
|   <h2 :if={@use_count && @use_count != 0} class="title text-md"> |   <h2 :if={@use_count && @use_count != 0} class="title text-md"> | ||||||
|     <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> |     {gettext("Uses: %{uses_count}", uses_count: @use_count)} | ||||||
|   </h2> |   </h2> | ||||||
|  |  | ||||||
|   <div class="flex flex-row flex-wrap justify-center items-center"> |   <div class="flex flex-row flex-wrap justify-center items-center"> | ||||||
|     <code |     <code | ||||||
|       id={"code-#{@invite.id}"} |       id={"code-#{@invite.id}"} | ||||||
|       class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" |       class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-zinc-100 bg-primary-800" | ||||||
|       phx-no-format |       phx-no-format | ||||||
|     ><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code> |     ><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code> | ||||||
|     <%= if @code_actions, do: render_slot(@code_actions) %> |     {if @code_actions, do: render_slot(@code_actions)} | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> |   <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||||
|     <%= render_slot(@inner_block) %> |     {render_slot(@inner_block)} | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ | |||||||
|       patch={@return_to} |       patch={@return_to} | ||||||
|       id="close" |       id="close" | ||||||
|       class="absolute top-8 right-10 |       class="absolute top-8 right-10 | ||||||
|         text-gray-500 hover:text-gray-800 |         text-zinc-500 hover:text-zinc-800 | ||||||
|         transition-all duration-500 ease-in-out" |         transition-all duration-500 ease-in-out" | ||||||
|       phx-remove={hide_modal()} |       phx-remove={hide_modal()} | ||||||
|       aria-label={gettext("Close modal")} |       aria-label={gettext("Close modal")} | ||||||
| @@ -38,7 +38,7 @@ | |||||||
|     </.link> |     </.link> | ||||||
|  |  | ||||||
|     <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> |     <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> | ||||||
|       <%= render_slot(@inner_block) %> |       {render_slot(@inner_block)} | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -2,66 +2,66 @@ | |||||||
|   id={"pack-#{@pack.id}"} |   id={"pack-#{@pack.id}"} | ||||||
|   class="mx-4 my-2 px-8 py-4 |   class="mx-4 my-2 px-8 py-4 | ||||||
|     flex flex-col justify-center items-center |     flex flex-col justify-center items-center | ||||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md |     border border-zinc-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|     transition-all duration-300 ease-in-out" |     transition-all duration-300 ease-in-out" | ||||||
| > | > | ||||||
|   <.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link"> |   <.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link"> | ||||||
|     <h1 class="title text-xl title-primary-500"> |     <h1 class="title text-xl title-primary-500"> | ||||||
|       <%= @pack.type.name %> |       {@pack.type.name} | ||||||
|     </h1> |     </h1> | ||||||
|   </.link> |   </.link> | ||||||
|  |  | ||||||
|   <div class="flex flex-col justify-center items-center"> |   <div class="flex flex-col justify-center items-center"> | ||||||
|     <span class="rounded-lg title text-lg"> |     <span class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Count:") %> |       {gettext("Count:")} | ||||||
|       <%= if @pack.count == 0, do: gettext("Empty"), else: @pack.count %> |       {if @pack.count == 0, do: gettext("Empty"), else: @pack.count} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg"> |     <span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Original Count:") %> |       {gettext("Original Count:")} | ||||||
|       <%= @original_count %> |       {@original_count} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@pack.notes} class="rounded-lg title text-lg"> |     <span :if={@pack.notes} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Notes:") %> |       {gettext("Notes:")} | ||||||
|       <%= @pack.notes %> |       {@pack.notes} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@pack.purchased_on} class="rounded-lg title text-lg"> |     <span :if={@pack.purchased_on} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Purchased on:") %> |       {gettext("Purchased on:")} | ||||||
|       <.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} /> |       <.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} /> | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@last_used_date} class="rounded-lg title text-lg"> |     <span :if={@last_used_date} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Last used on:") %> |       {gettext("Last used on:")} | ||||||
|       <.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} /> |       <.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} /> | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@pack.price_paid} class="rounded-lg title text-lg"> |     <span :if={@pack.price_paid} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Price paid:") %> |       {gettext("Price paid:")} | ||||||
|       <%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %> |       {gettext("$%{amount}", amount: display_currency(@pack.price_paid))} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@cpr} class="rounded-lg title text-lg"> |     <span :if={@cpr} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("CPR:") %> |       {gettext("CPR:")} | ||||||
|       <%= gettext("$%{amount}", amount: display_currency(@cpr)) %> |       {gettext("$%{amount}", amount: display_currency(@cpr))} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@pack.lot_number} class="rounded-lg title text-lg"> |     <span :if={@pack.lot_number} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Lot number:") %> |       {gettext("Lot number:")} | ||||||
|       <%= @pack.lot_number %> |       {@pack.lot_number} | ||||||
|     </span> |     </span> | ||||||
|  |  | ||||||
|     <span :if={@container} class="rounded-lg title text-lg"> |     <span :if={@container} class="rounded-lg title text-lg"> | ||||||
|       <%= gettext("Container:") %> |       {gettext("Container:")} | ||||||
|  |  | ||||||
|       <.link navigate={~p"/container/#{@container}"} class="link"> |       <.link navigate={~p"/container/#{@container}"} class="link"> | ||||||
|         <%= @container.name %> |         {@container.name} | ||||||
|       </.link> |       </.link> | ||||||
|     </span> |     </span> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center"> |   <div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center"> | ||||||
|     <%= render_slot(@inner_block) %> |     {render_slot(@inner_block)} | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -2,5 +2,5 @@ | |||||||
|   class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl" |   class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl" | ||||||
|   style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"} |   style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"} | ||||||
| > | > | ||||||
|   <%= @tag.name %> |   {@tag.name} | ||||||
| </h1> | </h1> | ||||||
|   | |||||||
| @@ -1,9 +1,9 @@ | |||||||
| <div | <div | ||||||
|   id={"tag-#{@tag.id}"} |   id={"tag-#{@tag.id}"} | ||||||
|   class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center |   class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center | ||||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md |     border border-zinc-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|     transition-all duration-300 ease-in-out" |     transition-all duration-300 ease-in-out" | ||||||
| > | > | ||||||
|   <.simple_tag_card tag={@tag} /> |   <.simple_tag_card tag={@tag} /> | ||||||
|   <%= render_slot(@inner_block) %> |   {render_slot(@inner_block)} | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> | <label for={@id || @action} class="inline-flex relative items-center cursor-pointer"> | ||||||
|   <input |   <input | ||||||
|     id={@id || @action} |     id={@id || @action} | ||||||
|     type="checkbox" |     type="checkbox" | ||||||
| @@ -12,19 +12,17 @@ | |||||||
|         else: %{"phx-click": @action, "phx-value-value": @value} |         else: %{"phx-click": @action, "phx-value-value": @value} | ||||||
|     } |     } | ||||||
|   /> |   /> | ||||||
|   <div class="w-11 h-6 bg-gray-300 rounded-full peer |   <div class="w-11 h-6 bg-zinc-300 rounded-full peer | ||||||
|     peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 |     peer-checked:bg-zinc-600 peer-checked:after:translate-x-full peer-checked:after:border-white | ||||||
|     peer-checked:bg-gray-600 |     after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 | ||||||
|     peer-checked:after:translate-x-full peer-checked:after:border-white |  | ||||||
|     after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 |  | ||||||
|     after:border after:rounded-full after:h-5 after:w-5 |     after:border after:rounded-full after:h-5 after:w-5 | ||||||
|     after:transition-all after:duration-250 after:ease-in-out |     after:transition-all after:duration-250 after:ease-in-out | ||||||
|     transition-colors duration-250 ease-in-out"> |     transition-colors duration-250 ease-in-out"> | ||||||
|   </div> |   </div> | ||||||
|   <span |   <span | ||||||
|     id={"#{@id || @action}-label"} |     id={"#{@id || @action}-label"} | ||||||
|     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" |     class="ml-3 text-sm font-medium whitespace-nowrap text-zinc-900 dark:text-zinc-300" | ||||||
|   > |   > | ||||||
|     <%= render_slot(@inner_block) %> |     {render_slot(@inner_block)} | ||||||
|   </span> |   </span> | ||||||
| </label> | </label> | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
|         <span class="mx-2 my-1"> |         <span class="mx-2 my-1"> | ||||||
|           | |           | | ||||||
|         </span> |         </span> | ||||||
|         <%= @title_content %> |         {@title_content} | ||||||
|       <% end %> |       <% end %> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
| @@ -25,37 +25,37 @@ | |||||||
|       <%= if @current_user do %> |       <%= if @current_user do %> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/tags"} class="text-white hover:underline"> |           <.link navigate={~p"/tags"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Tags") %> |             {gettext("Tags")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/containers"} class="text-white hover:underline"> |           <.link navigate={~p"/containers"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Containers") %> |             {gettext("Containers")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/catalog"} class="text-white hover:underline"> |           <.link navigate={~p"/catalog"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Catalog") %> |             {gettext("Catalog")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/ammo"} class="text-white hover:underline"> |           <.link navigate={~p"/ammo"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Ammo") %> |             {gettext("Ammo")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/range"} class="text-white hover:underline"> |           <.link navigate={~p"/range"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Range") %> |             {gettext("Range")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1"> |         <li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1"> | ||||||
|           <.link navigate={~p"/invites"} class="text-white hover:underline"> |           <.link navigate={~p"/invites"} class="text-white hover:underline"> | ||||||
|             <%= gettext("Invites") %> |             {gettext("Invites")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link href={~p"/users/settings"} class="text-white hover:underline truncate"> |           <.link href={~p"/users/settings"} class="text-white hover:underline truncate"> | ||||||
|             <%= @current_user.email %> |             {@current_user.email} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
| @@ -70,7 +70,7 @@ | |||||||
|         </li> |         </li> | ||||||
|         <li |         <li | ||||||
|           :if={ |           :if={ | ||||||
|             @current_user |> Accounts.is_already_admin?() and |             @current_user |> Accounts.already_admin?() and | ||||||
|               function_exported?(Routes, :live_dashboard_path, 2) |               function_exported?(Routes, :live_dashboard_path, 2) | ||||||
|           } |           } | ||||||
|           class="mx-2 my-1" |           class="mx-2 my-1" | ||||||
| @@ -86,12 +86,12 @@ | |||||||
|       <% else %> |       <% else %> | ||||||
|         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> |         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||||
|           <.link href={~p"/users/register"} class="text-white hover:underline truncate"> |           <.link href={~p"/users/register"} class="text-white hover:underline truncate"> | ||||||
|             <%= dgettext("actions", "Register") %> |             {dgettext("actions", "Register")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|         <li class="mx-2 my-1"> |         <li class="mx-2 my-1"> | ||||||
|           <.link href={~p"/users/log_in"} class="text-white hover:underline truncate"> |           <.link href={~p"/users/log_in"} class="text-white hover:underline truncate"> | ||||||
|             <%= dgettext("actions", "Log in") %> |             {dgettext("actions", "Log in")} | ||||||
|           </.link> |           </.link> | ||||||
|         </li> |         </li> | ||||||
|       <% end %> |       <% end %> | ||||||
|   | |||||||
| @@ -1,36 +1,36 @@ | |||||||
| <div | <div | ||||||
|   id={"user-#{@user.id}"} |   id={"user-#{@user.id}"} | ||||||
|   class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center |   class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md |     border border-zinc-400 rounded-lg shadow-lg hover:shadow-md | ||||||
|     transition-all duration-300 ease-in-out" |     transition-all duration-300 ease-in-out" | ||||||
| > | > | ||||||
|   <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> |   <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||||
|     <%= @user.email %> |     {@user.email} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <h3 class="px-4 py-2 rounded-lg title text-lg"> |   <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||||
|     <p> |     <p> | ||||||
|       <%= if @user.confirmed_at do %> |       <%= if @user.confirmed_at do %> | ||||||
|         <%= gettext( |         {gettext( | ||||||
|           "User was confirmed at%{confirmed_datetime}", |           "User was confirmed at%{confirmed_datetime}", | ||||||
|           confirmed_datetime: "" |           confirmed_datetime: "" | ||||||
|         ) %> |         )} | ||||||
|         <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> |         <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> | ||||||
|       <% else %> |       <% else %> | ||||||
|         <%= gettext("Email unconfirmed") %> |         {gettext("Email unconfirmed")} | ||||||
|       <% end %> |       <% end %> | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|     <p> |     <p> | ||||||
|       <%= gettext( |       {gettext( | ||||||
|         "User registered on%{registered_datetime}", |         "User registered on%{registered_datetime}", | ||||||
|         registered_datetime: "" |         registered_datetime: "" | ||||||
|       ) %> |       )} | ||||||
|       <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> |       <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> | ||||||
|     </p> |     </p> | ||||||
|   </h3> |   </h3> | ||||||
|  |  | ||||||
|   <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> |   <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||||
|     <%= render_slot(@inner_block) %> |     {render_slot(@inner_block)} | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,23 +1,23 @@ | |||||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||||
|   <span style="margin-bottom: 0.75em; font-size: 1.5em;"> |   <span style="margin-bottom: 0.75em; font-size: 1.5em;"> | ||||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> |     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <span style="margin-bottom: 1em; font-size: 1.25em;"> |   <span style="margin-bottom: 1em; font-size: 1.25em;"> | ||||||
|     <%= dgettext("emails", "Welcome to Cannery") %> |     {dgettext("emails", "Welcome to Cannery")} | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> |   {dgettext("emails", "You can confirm your account by visiting the URL below:")} | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> |   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext("emails", "If you didn't create an account at Cannery, please ignore this.") %> |   {dgettext("emails", "If you didn't create an account at Cannery, please ignore this.")} | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,17 +1,17 @@ | |||||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> |   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> |     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext("emails", "You can reset your password by visiting the URL below:") %> |   {dgettext("emails", "You can reset your password by visiting the URL below:")} | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> |   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext("emails", "If you didn't request this change from Cannery, please ignore this.") %> |   {dgettext("emails", "If you didn't request this change from Cannery, please ignore this.")} | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,20 +1,20 @@ | |||||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> |   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> |     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||||
|   </span> |   </span> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext("emails", "You can change your email by visiting the URL below:") %> |   {dgettext("emails", "You can change your email by visiting the URL below:")} | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> |   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||||
|  |  | ||||||
|   <br /> |   <br /> | ||||||
|  |  | ||||||
|   <%= dgettext( |   {dgettext( | ||||||
|     "emails", |     "emails", | ||||||
|     "If you didn't request this change from Cannery, please ignore this." |     "If you didn't request this change from Cannery, please ignore this." | ||||||
|   ) %> |   )} | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,18 +1,45 @@ | |||||||
| <main role="main" class="min-h-full min-w-full"> | <main class="pb-8 min-w-full"> | ||||||
|   <header> |   <header> | ||||||
|     <.topbar current_user={assigns[:current_user]} /> |     <.topbar current_user={assigns[:current_user]} /> | ||||||
|  |  | ||||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> |     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||||
|       <p :if={@flash["info"]} class="alert alert-info" role="alert"> |       <p | ||||||
|         <%= @flash["info"] %> |         :if={@flash && @flash |> Map.has_key?("info")} | ||||||
|  |         class="alert alert-info" | ||||||
|  |         role="alert" | ||||||
|  |         phx-click="lv:clear-flash" | ||||||
|  |         phx-value-key="info" | ||||||
|  |       > | ||||||
|  |         {Phoenix.Flash.get(@flash, :info)} | ||||||
|       </p> |       </p> | ||||||
|       <p :if={@flash["error"]} class="alert alert-danger" role="alert"> |  | ||||||
|         <%= @flash["error"] %> |       <p | ||||||
|  |         :if={@flash && @flash |> Map.has_key?("error")} | ||||||
|  |         class="alert alert-danger" | ||||||
|  |         role="alert" | ||||||
|  |         phx-click="lv:clear-flash" | ||||||
|  |         phx-value-key="error" | ||||||
|  |       > | ||||||
|  |         {Phoenix.Flash.get(@flash, :error)} | ||||||
|       </p> |       </p> | ||||||
|     </div> |     </div> | ||||||
|   </header> |   </header> | ||||||
|  |  | ||||||
|   <div class="mx-4 sm:mx-8 md:mx-16"> |   <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> | ||||||
|     <%= @inner_content %> |     {@inner_content} | ||||||
|   </div> |   </div> | ||||||
| </main> | </main> | ||||||
|  |  | ||||||
|  | <div | ||||||
|  |   id="disconnect" | ||||||
|  |   class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max | ||||||
|  |   border border-primary-200 shadow-lg rounded-lg bg-white | ||||||
|  |   flex justify-center items-center space-x-4 | ||||||
|  |   transition-opacity ease-in-out duration-500 delay-[2000ms]" | ||||||
|  | > | ||||||
|  |   <i class="fas fa-fade text-md fa-satellite-dish"></i> | ||||||
|  |  | ||||||
|  |   <h1 class="title text-md title-primary-500"> | ||||||
|  |     {gettext("Reconnecting...")} | ||||||
|  |   </h1> | ||||||
|  | </div> | ||||||
|   | |||||||
| @@ -1,19 +1,19 @@ | |||||||
| <html> | <html> | ||||||
|   <head> |   <head> | ||||||
|     <title> |     <title> | ||||||
|       <%= @email.subject %> |       {@email.subject} | ||||||
|     </title> |     </title> | ||||||
|   </head> |   </head> | ||||||
|   <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> |   <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> | ||||||
|     <%= @inner_content %> |     {@inner_content} | ||||||
|  |  | ||||||
|     <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> |     <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> | ||||||
|  |  | ||||||
|     <a style="color: rgb(31, 31, 31);" href={~p"/"}> |     <a style="color: rgb(31, 31, 31);" href={~p"/"}> | ||||||
|       <%= dgettext( |       {dgettext( | ||||||
|         "emails", |         "emails", | ||||||
|         "This email was sent from Cannery, the self-hosted firearm tracker website." |         "This email was sent from Cannery, the self-hosted firearm tracker website." | ||||||
|       ) %> |       )} | ||||||
|     </a> |     </a> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								lib/cannery_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/cannery_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {@inner_block} | ||||||
| @@ -1,45 +0,0 @@ | |||||||
| <main class="pb-8 min-w-full"> |  | ||||||
|   <header> |  | ||||||
|     <.topbar current_user={assigns[:current_user]} /> |  | ||||||
|  |  | ||||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> |  | ||||||
|       <p |  | ||||||
|         :if={@flash && @flash |> Map.has_key?("info")} |  | ||||||
|         class="alert alert-info" |  | ||||||
|         role="alert" |  | ||||||
|         phx-click="lv:clear-flash" |  | ||||||
|         phx-value-key="info" |  | ||||||
|       > |  | ||||||
|         <%= live_flash(@flash, "info") %> |  | ||||||
|       </p> |  | ||||||
|  |  | ||||||
|       <p |  | ||||||
|         :if={@flash && @flash |> Map.has_key?("error")} |  | ||||||
|         class="alert alert-danger" |  | ||||||
|         role="alert" |  | ||||||
|         phx-click="lv:clear-flash" |  | ||||||
|         phx-value-key="error" |  | ||||||
|       > |  | ||||||
|         <%= live_flash(@flash, "error") %> |  | ||||||
|       </p> |  | ||||||
|     </div> |  | ||||||
|   </header> |  | ||||||
|  |  | ||||||
|   <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> |  | ||||||
|     <%= @inner_content %> |  | ||||||
|   </div> |  | ||||||
| </main> |  | ||||||
|  |  | ||||||
| <div |  | ||||||
|   id="disconnect" |  | ||||||
|   class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max |  | ||||||
|   border border-primary-200 shadow-lg rounded-lg bg-white |  | ||||||
|   flex justify-center items-center space-x-4 |  | ||||||
|   transition-opacity ease-in-out duration-500 delay-[2000ms]" |  | ||||||
| > |  | ||||||
|   <i class="fas fa-fade text-md fa-satellite-dish"></i> |  | ||||||
|  |  | ||||||
|   <h1 class="title text-md title-primary-500"> |  | ||||||
|     <%= gettext("Reconnecting...") %> |  | ||||||
|   </h1> |  | ||||||
| </div> |  | ||||||
| @@ -1,20 +1,19 @@ | |||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <html lang="en" class="m-0 p-0 w-full h-full bg-white"> | <html lang="en" class="p-0 m-0 w-full h-full bg-white [scrollbar-gutter:stable]"> | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="utf-8" /> |     <meta charset="utf-8" /> | ||||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> |     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |     <meta name="csrf-token" content={get_csrf_token()} /> | ||||||
|     <%= csrf_meta_tag() %> |  | ||||||
|     <link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} /> |     <link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} /> | ||||||
|     <.live_title suffix={" | #{gettext("Cannery")}"}> |     <.live_title suffix={" | #{gettext("Cannery")}"}> | ||||||
|       <%= assigns[:page_title] || gettext("Cannery") %> |       {assigns[:page_title] || gettext("Cannery")} | ||||||
|     </.live_title> |     </.live_title> | ||||||
|     <link phx-track-static rel="stylesheet" href={~p"/css/app.css"} /> |     <link phx-track-static rel="stylesheet" href={~p"/assets/style.css"} /> | ||||||
|     <script defer phx-track-static type="text/javascript" src={~p"/js/app.js"}> |     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> | ||||||
|     </script> |     </script> | ||||||
|   </head> |   </head> | ||||||
|  |  | ||||||
|   <body class="m-0 p-0 w-full h-full subpixel-antialiased"> |   <body class="p-0 m-0 w-full h-full subpixel-antialiased"> | ||||||
|     <%= @inner_content %> |     {@inner_content} | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -74,22 +74,22 @@ defmodule CanneryWeb.Components.MovePackComponent do | |||||||
|     ~H""" |     ~H""" | ||||||
|     <div class="w-full flex flex-col space-y-8 justify-center items-center"> |     <div class="w-full flex flex-col space-y-8 justify-center items-center"> | ||||||
|       <h2 class="mb-8 text-center title text-xl text-primary-600"> |       <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||||
|         <%= dgettext("actions", "Move ammo") %> |         {dgettext("actions", "Move ammo")} | ||||||
|       </h2> |       </h2> | ||||||
|  |  | ||||||
|       <%= if @containers |> Enum.empty?() do %> |       <%= if @containers |> Enum.empty?() do %> | ||||||
|         <h2 class="title text-xl text-primary-600"> |         <h2 class="title text-xl text-primary-600"> | ||||||
|           <%= gettext("No other containers") %> |           {gettext("No other containers")} | ||||||
|           <%= display_emoji("😔") %> |           {display_emoji("😔")} | ||||||
|         </h2> |         </h2> | ||||||
|  |  | ||||||
|         <.link navigate={~p"/containers/new"} class="btn btn-primary"> |         <.link navigate={~p"/containers/new"} class="btn btn-primary"> | ||||||
|           <%= dgettext("actions", "Add another container!") %> |           {dgettext("actions", "Add another container!")} | ||||||
|         </.link> |         </.link> | ||||||
|       <% else %> |       <% else %> | ||||||
|         <.live_component |         <.live_component | ||||||
|           module={CanneryWeb.Components.TableComponent} |           module={CanneryWeb.Components.TableComponent} | ||||||
|           id="move_pack_table" |           id="move-pack-table" | ||||||
|           columns={@columns} |           columns={@columns} | ||||||
|           rows={@rows} |           rows={@rows} | ||||||
|         /> |         /> | ||||||
| @@ -120,7 +120,7 @@ defmodule CanneryWeb.Components.MovePackComponent do | |||||||
|         phx-target={@myself} |         phx-target={@myself} | ||||||
|         phx-value-container_id={@container.id} |         phx-value-container_id={@container.id} | ||||||
|       > |       > | ||||||
|         <%= dgettext("actions", "Select") %> |         {dgettext("actions", "Select")} | ||||||
|       </button> |       </button> | ||||||
|     </div> |     </div> | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -141,7 +141,12 @@ defmodule CanneryWeb.Components.PackTableComponent do | |||||||
|   def render(assigns) do |   def render(assigns) do | ||||||
|     ~H""" |     ~H""" | ||||||
|     <div id={@id} class="w-full"> |     <div id={@id} class="w-full"> | ||||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> |       <.live_component | ||||||
|  |         module={TableComponent} | ||||||
|  |         id={"pack-table-#{@id}"} | ||||||
|  |         columns={@columns} | ||||||
|  |         rows={@rows} | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
| @@ -165,7 +170,7 @@ defmodule CanneryWeb.Components.PackTableComponent do | |||||||
|  |  | ||||||
|     {type_name, |     {type_name, | ||||||
|      ~H""" |      ~H""" | ||||||
|      <%= render_slot(@type_block, @type) %> |      {render_slot(@type_block, @type)} | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -191,18 +196,17 @@ defmodule CanneryWeb.Components.PackTableComponent do | |||||||
|      <%= if @last_used_date do %> |      <%= if @last_used_date do %> | ||||||
|        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> |        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> | ||||||
|      <% else %> |      <% else %> | ||||||
|        <%= gettext("Never used") %> |        {gettext("Never used")} | ||||||
|      <% end %> |      <% end %> | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do |   defp get_value_for_key(:range, pack, %{range: range}) do | ||||||
|     assigns = %{range: range, pack: pack} |     assigns = %{range: range, pack: pack} | ||||||
|  |  | ||||||
|     {staged, |  | ||||||
|     ~H""" |     ~H""" | ||||||
|      <%= render_slot(@range, @pack) %> |     {render_slot(@range, @pack)} | ||||||
|      """} |     """ | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp get_value_for_key( |   defp get_value_for_key( | ||||||
| @@ -218,7 +222,7 @@ defmodule CanneryWeb.Components.PackTableComponent do | |||||||
|     assigns = %{actions: actions, pack: pack} |     assigns = %{actions: actions, pack: pack} | ||||||
|  |  | ||||||
|     ~H""" |     ~H""" | ||||||
|     <%= render_slot(@actions, @pack) %> |     {render_slot(@actions, @pack)} | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -239,7 +243,7 @@ defmodule CanneryWeb.Components.PackTableComponent do | |||||||
|  |  | ||||||
|     {container_name, |     {container_name, | ||||||
|      ~H""" |      ~H""" | ||||||
|      <%= render_slot(@container_block, {@pack, @container}) %> |      {render_slot(@container_block, {@pack, @container})} | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -74,7 +74,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do | |||||||
|     <div id={@id} class="w-full"> |     <div id={@id} class="w-full"> | ||||||
|       <.live_component |       <.live_component | ||||||
|         module={CanneryWeb.Components.TableComponent} |         module={CanneryWeb.Components.TableComponent} | ||||||
|         id={"table-#{@id}"} |         id={"shot-record-table-#{@id}"} | ||||||
|         columns={@columns} |         columns={@columns} | ||||||
|         rows={@rows} |         rows={@rows} | ||||||
|         initial_key={:date} |         initial_key={:date} | ||||||
| @@ -99,7 +99,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do | |||||||
|     {pack.type.name, |     {pack.type.name, | ||||||
|      ~H""" |      ~H""" | ||||||
|      <.link navigate={~p"/ammo/show/#{@pack}"} class="link"> |      <.link navigate={~p"/ammo/show/#{@pack}"} class="link"> | ||||||
|        <%= @pack.type.name %> |        {@pack.type.name} | ||||||
|      </.link> |      </.link> | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
| @@ -115,7 +115,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do | |||||||
|     assigns = %{actions: actions, shot_record: shot_record} |     assigns = %{actions: actions, shot_record: shot_record} | ||||||
|  |  | ||||||
|     ~H""" |     ~H""" | ||||||
|     <%= render_slot(@actions, @shot_record) %> |     {render_slot(@actions, @shot_record)} | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :live_component |   use CanneryWeb, :live_component | ||||||
|  |   alias Cannery.{ComparableDate, ComparableDateTime} | ||||||
|   alias Phoenix.LiveView.Socket |   alias Phoenix.LiveView.Socket | ||||||
|   require Integer |   require Integer | ||||||
|  |  | ||||||
| @@ -75,7 +76,7 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|         sort_mode: initial_sort_mode |         sort_mode: initial_sort_mode | ||||||
|       ) |       ) | ||||||
|       |> assign_new(:row_class, fn -> "bg-white" end) |       |> assign_new(:row_class, fn -> "bg-white" end) | ||||||
|       |> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) |       |> assign_new(:alternate_row_class, fn -> "bg-zinc-200" end) | ||||||
|  |  | ||||||
|     {:ok, socket} |     {:ok, socket} | ||||||
|   end |   end | ||||||
| @@ -110,7 +111,7 @@ defmodule CanneryWeb.Components.TableComponent do | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) |   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) | ||||||
|        when type in [Date, DateTime] do |        when type in [ComparableDate, ComparableDateTime, Date, DateTime] do | ||||||
|     rows |     rows | ||||||
|     |> Enum.sort_by( |     |> Enum.sort_by( | ||||||
|       fn row -> |       fn row -> | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| <div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white"> | <div id={@id} class="w-full overflow-x-auto border border-zinc-600 rounded-lg shadow-lg bg-white"> | ||||||
|   <table class="min-w-full table-auto text-center bg-white"> |   <table class="min-w-full table-auto text-center bg-white"> | ||||||
|     <thead class="border-b border-primary-600"> |     <thead class="border-b border-primary-600"> | ||||||
|       <tr> |       <tr> | ||||||
|  |         <th class="p-2 w-12">{gettext("Row")}</th> | ||||||
|         <%= 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]]}> | ||||||
| @@ -12,7 +13,7 @@ | |||||||
|                 phx-target={@myself} |                 phx-target={@myself} | ||||||
|               > |               > | ||||||
|                 <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> |                 <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||||
|                 <span class={if @last_sort_key == key, do: "underline"}><%= label %></span> |                 <span class={if @last_sort_key == key, do: "underline"}>{label}</span> | ||||||
|                 <%= if @last_sort_key == key do %> |                 <%= if @last_sort_key == key do %> | ||||||
|                   <%= case @sort_mode do %> |                   <%= case @sort_mode do %> | ||||||
|                     <% :asc -> %> |                     <% :asc -> %> | ||||||
| @@ -27,7 +28,7 @@ | |||||||
|             </th> |             </th> | ||||||
|           <% else %> |           <% else %> | ||||||
|             <th class={["p-2 cursor-not-allowed", column[:class]]}> |             <th class={["p-2 cursor-not-allowed", column[:class]]}> | ||||||
|               <%= label %> |               {label} | ||||||
|             </th> |             </th> | ||||||
|           <% end %> |           <% end %> | ||||||
|         <% end %> |         <% end %> | ||||||
| @@ -38,12 +39,13 @@ | |||||||
|         :for={{values, i} <- @rows |> Enum.with_index()} |         :for={{values, i} <- @rows |> Enum.with_index()} | ||||||
|         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} |         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} | ||||||
|       > |       > | ||||||
|  |         <td class="p-2">{i + 1}</td> | ||||||
|         <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> |         <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> | ||||||
|           <%= case values |> Map.get(key) do %> |           <%= case values |> Map.get(key) do %> | ||||||
|             <% {_custom_sort_value, value} -> %> |             <% {_custom_sort_value, value} -> %> | ||||||
|               <%= value %> |               {value} | ||||||
|             <% value -> %> |             <% value -> %> | ||||||
|               <%= value %> |               {value} | ||||||
|           <% end %> |           <% end %> | ||||||
|         </td> |         </td> | ||||||
|       </tr> |       </tr> | ||||||
|   | |||||||
| @@ -151,17 +151,25 @@ defmodule CanneryWeb.Components.TypeTableComponent do | |||||||
|       ) |       ) | ||||||
|       |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) |       |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) | ||||||
|  |  | ||||||
|     round_counts = types |> Ammo.get_round_count_for_types(current_user) |     round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id) | ||||||
|     packs_count = types |> Ammo.get_packs_count_for_types(current_user) |     packs_count = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id) | ||||||
|     average_costs = types |> Ammo.get_average_cost_for_types(current_user) |     average_costs = Ammo.get_average_costs(types, current_user) | ||||||
|  |  | ||||||
|     [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = |     [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = | ||||||
|       if show_used do |       if show_used do | ||||||
|         [ |         [ | ||||||
|           types |> ActivityLog.get_used_count_for_types(current_user), |           ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id), | ||||||
|           types |> Ammo.get_historical_count_for_types(current_user), |           Ammo.get_historical_counts(types, current_user), | ||||||
|           types |> Ammo.get_packs_count_for_types(current_user, true), |           Ammo.get_grouped_packs_count(current_user, | ||||||
|           types |> Ammo.get_used_packs_count_for_types(current_user) |             types: types, | ||||||
|  |             group_by: :type_id, | ||||||
|  |             show_used: true | ||||||
|  |           ), | ||||||
|  |           Ammo.get_grouped_packs_count(current_user, | ||||||
|  |             types: types, | ||||||
|  |             group_by: :type_id, | ||||||
|  |             show_used: :only_used | ||||||
|  |           ) | ||||||
|         ] |         ] | ||||||
|       else |       else | ||||||
|         [nil, nil, nil, nil] |         [nil, nil, nil, nil] | ||||||
| @@ -192,7 +200,12 @@ defmodule CanneryWeb.Components.TypeTableComponent do | |||||||
|   def render(assigns) do |   def render(assigns) do | ||||||
|     ~H""" |     ~H""" | ||||||
|     <div id={@id} class="w-full"> |     <div id={@id} class="w-full"> | ||||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> |       <.live_component | ||||||
|  |         module={TableComponent} | ||||||
|  |         id={"type-table-#{@id}"} | ||||||
|  |         columns={@columns} | ||||||
|  |         rows={@rows} | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
| @@ -265,7 +278,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do | |||||||
|     {type_name, |     {type_name, | ||||||
|      ~H""" |      ~H""" | ||||||
|      <.link navigate={~p"/type/#{@id}"} class="link"> |      <.link navigate={~p"/type/#{@id}"} class="link"> | ||||||
|        <%= @name %> |        {@name} | ||||||
|      </.link> |      </.link> | ||||||
|      """} |      """} | ||||||
|   end |   end | ||||||
| @@ -274,7 +287,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do | |||||||
|     assigns = %{actions: actions, type: type} |     assigns = %{actions: actions, type: type} | ||||||
|  |  | ||||||
|     ~H""" |     ~H""" | ||||||
|     <%= render_slot(@actions, @type) %> |     {render_slot(@actions, @type)} | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> |     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
|     <title> |     <title> | ||||||
|       <%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %> |       {dgettext("errors", "Error")} | {gettext("Cannery")} | ||||||
|     </title> |     </title> | ||||||
|     <link rel="stylesheet" href="/css/app.css" /> |     <link rel="stylesheet" href="/css/app.css" /> | ||||||
|     <script defer type="text/javascript" src="/js/app.js"> |     <script defer type="text/javascript" src="/js/app.js"> | ||||||
| @@ -19,13 +19,13 @@ | |||||||
|     <div class="pb-8 w-full flex flex-col justify-center items-center text-center"> |     <div class="pb-8 w-full flex flex-col justify-center items-center text-center"> | ||||||
|       <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl"> |       <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl"> | ||||||
|         <h1 class="title text-primary-600 text-3xl"> |         <h1 class="title text-primary-600 text-3xl"> | ||||||
|           <%= @error_string %> |           {@error_string} | ||||||
|         </h1> |         </h1> | ||||||
|  |  | ||||||
|         <hr class="w-full hr" /> |         <hr class="w-full hr" /> | ||||||
|  |  | ||||||
|         <.link href={~p"/"} class="link title text-primary-600 text-lg"> |         <.link href={~p"/"} class="link title text-primary-600 text-lg"> | ||||||
|           <%= dgettext("errors", "Go back home") %> |           {dgettext("errors", "Go back home")} | ||||||
|         </.link> |         </.link> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| defmodule CanneryWeb.ErrorJSON do | defmodule CanneryWeb.ErrorJSON do | ||||||
|   import CanneryWeb.Gettext |   use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |  | ||||||
|   def render(template, _assigns) do |   def render(template, _assigns) do | ||||||
|     error_string = |     error_string = | ||||||
|   | |||||||
| @@ -3,14 +3,22 @@ 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 | ||||||
|     types = Ammo.list_types(current_user, :all) |     types = Ammo.list_types(current_user) | ||||||
|     used_counts = types |> ActivityLog.get_used_count_for_types(current_user) |  | ||||||
|     round_counts = types |> Ammo.get_round_count_for_types(current_user) |  | ||||||
|     pack_counts = types |> Ammo.get_packs_count_for_types(current_user) |  | ||||||
|  |  | ||||||
|     total_pack_counts = types |> Ammo.get_packs_count_for_types(current_user, true) |     used_counts = | ||||||
|  |       ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id) | ||||||
|  |  | ||||||
|     average_costs = types |> Ammo.get_average_cost_for_types(current_user) |     round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id) | ||||||
|  |     pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id) | ||||||
|  |  | ||||||
|  |     total_pack_counts = | ||||||
|  |       Ammo.get_grouped_packs_count(current_user, | ||||||
|  |         types: types, | ||||||
|  |         group_by: :type_id, | ||||||
|  |         show_used: true | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |     average_costs = Ammo.get_average_costs(types, current_user) | ||||||
|  |  | ||||||
|     types = |     types = | ||||||
|       types |       types | ||||||
| @@ -27,8 +35,11 @@ defmodule CanneryWeb.ExportController do | |||||||
|         }) |         }) | ||||||
|       end) |       end) | ||||||
|  |  | ||||||
|     packs = Ammo.list_packs(nil, :all, current_user, true) |     packs = Ammo.list_packs(current_user, show_used: true) | ||||||
|     used_counts = packs |> ActivityLog.get_used_counts(current_user) |  | ||||||
|  |     used_counts = | ||||||
|  |       ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id) | ||||||
|  |  | ||||||
|     original_counts = packs |> Ammo.get_original_counts(current_user) |     original_counts = packs |> Ammo.get_original_counts(current_user) | ||||||
|     cprs = packs |> Ammo.get_cprs(current_user) |     cprs = packs |> Ammo.get_cprs(current_user) | ||||||
|     percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user) |     percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user) | ||||||
| @@ -47,20 +58,17 @@ defmodule CanneryWeb.ExportController do | |||||||
|         }) |         }) | ||||||
|       end) |       end) | ||||||
|  |  | ||||||
|     shot_records = ActivityLog.list_shot_records(:all, current_user) |     shot_records = ActivityLog.list_shot_records(current_user) | ||||||
|  |  | ||||||
|     containers = |     containers = | ||||||
|       Containers.list_containers(current_user) |       Containers.list_containers(current_user) | ||||||
|       |> Enum.map(fn container -> |       |> Enum.map(fn container -> | ||||||
|         pack_count = container |> Ammo.get_packs_count_for_container!(current_user) |  | ||||||
|         round_count = container |> Ammo.get_round_count_for_container!(current_user) |  | ||||||
|  |  | ||||||
|         container |         container | ||||||
|         |> Jason.encode!() |         |> Jason.encode!() | ||||||
|         |> Jason.decode!() |         |> Jason.decode!() | ||||||
|         |> Map.merge(%{ |         |> Map.merge(%{ | ||||||
|           "pack_count" => pack_count, |           "pack_count" => Ammo.get_packs_count(current_user, container_id: container.id), | ||||||
|           "round_count" => round_count |           "round_count" => Ammo.get_round_count(current_user, container_id: container.id) | ||||||
|         }) |         }) | ||||||
|       end) |       end) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,9 +4,9 @@ defmodule CanneryWeb.UserAuth do | |||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use CanneryWeb, :verified_routes |   use CanneryWeb, :verified_routes | ||||||
|  |   use Gettext, backend: CanneryWeb.Gettext | ||||||
|   import Plug.Conn |   import Plug.Conn | ||||||
|   import Phoenix.Controller |   import Phoenix.Controller | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Cannery.{Accounts, Accounts.User} |   alias Cannery.{Accounts, Accounts.User} | ||||||
|  |  | ||||||
|   # Make the remember me cookie valid for 60 days. |   # Make the remember me cookie valid for 60 days. | ||||||
|   | |||||||
| @@ -1,7 +1,5 @@ | |||||||
| defmodule CanneryWeb.UserConfirmationController do | defmodule CanneryWeb.UserConfirmationController do | ||||||
|   use CanneryWeb, :controller |   use CanneryWeb, :controller | ||||||
|  |  | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Cannery.Accounts |   alias Cannery.Accounts | ||||||
|  |  | ||||||
|   def new(conn, _params) do |   def new(conn, _params) do | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||||
|   <h1 class="title text-primary-600 text-xl"> |   <h1 class="title text-primary-600 text-xl"> | ||||||
|     <%= dgettext("actions", "Resend confirmation instructions") %> |     {dgettext("actions", "Resend confirmation instructions")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -10,22 +10,22 @@ | |||||||
|     action={~p"/users/confirm"} |     action={~p"/users/confirm"} | ||||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" |     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||||
|   > |   > | ||||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> |     {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")} | ||||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> |     {email_input(f, :email, required: true, class: "input input-primary col-span-2")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Resend confirmation instructions"), |     {submit(dgettext("actions", "Resend confirmation instructions"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3" |       class: "mx-auto btn btn-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-row justify-center items-center space-x-4"> |   <div class="flex flex-row justify-center items-center space-x-4"> | ||||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> |     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Register") %> |       {dgettext("actions", "Register")} | ||||||
|     </.link> |     </.link> | ||||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> |     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Log in") %> |       {dgettext("actions", "Log in")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| defmodule CanneryWeb.UserRegistrationController do | defmodule CanneryWeb.UserRegistrationController do | ||||||
|   use CanneryWeb, :controller |   use CanneryWeb, :controller | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Cannery.{Accounts, Accounts.Invites} |   alias Cannery.{Accounts, Accounts.Invites} | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|  |  | ||||||
| @@ -71,7 +70,7 @@ defmodule CanneryWeb.UserRegistrationController do | |||||||
|         |> redirect(to: ~p"/") |         |> redirect(to: ~p"/") | ||||||
|  |  | ||||||
|       {:error, %Changeset{} = changeset} -> |       {:error, %Changeset{} = changeset} -> | ||||||
|         conn |> render("new.html", changeset: changeset, invite_token: invite_token) |         conn |> render(:new, changeset: changeset, invite_token: invite_token) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||||
|   <h1 class="title text-primary-600 text-xl"> |   <h1 class="title text-primary-600 text-xl"> | ||||||
|     <%= dgettext("actions", "Register") %> |     {dgettext("actions", "Register")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -9,42 +9,47 @@ | |||||||
|     action={~p"/users/register"} |     action={~p"/users/register"} | ||||||
|     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" | ||||||
|   > |   > | ||||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> |     <p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3"> | ||||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> |       {dgettext("errors", "Oops, something went wrong! Please check the errors below.")} | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|     <%= if @invite_token do %> |     <%= if @invite_token do %> | ||||||
|       <%= hidden_input(f, :invite_token, value: @invite_token) %> |       {hidden_input(f, :invite_token, value: @invite_token)} | ||||||
|     <% end %> |     <% end %> | ||||||
|  |  | ||||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> |     {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")} | ||||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> |     {email_input(f, :email, required: true, class: "input input-primary col-span-2")} | ||||||
|     <%= error_tag(f, :email, "col-span-3") %> |     {error_tag(f, :email, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> |     {label(f, :password, gettext("Password"), class: "title text-lg text-primary-600")} | ||||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> |     {password_input(f, :password, required: true, class: "input input-primary col-span-2")} | ||||||
|     <%= error_tag(f, :password, "col-span-3") %> |     {error_tag(f, :password, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600") %> |     {label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600")} | ||||||
|     <%= select( |     {select( | ||||||
|       f, |       f, | ||||||
|       :locale, |       :locale, | ||||||
|       [{gettext("English"), "en_US"}], |       [ | ||||||
|  |         {"English", "en_US"}, | ||||||
|  |         {"Deutsch", "de"}, | ||||||
|  |         {"Français", "fr"}, | ||||||
|  |         {"Español", "es"} | ||||||
|  |       ], | ||||||
|       class: "input input-primary col-span-2" |       class: "input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :locale) %> |     {error_tag(f, :locale)} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %> |     {submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3")} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-row justify-center items-center space-x-4"> |   <div class="flex flex-row justify-center items-center space-x-4"> | ||||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> |     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Log in") %> |       {dgettext("actions", "Log in")} | ||||||
|     </.link> |     </.link> | ||||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> |     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Forgot your password?") %> |       {dgettext("actions", "Forgot your password?")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -40,7 +40,7 @@ defmodule CanneryWeb.UserResetPasswordController do | |||||||
|   # leaked token giving the user access to the account. |   # leaked token giving the user access to the account. | ||||||
|   def update(conn, %{"user" => user_params}) do |   def update(conn, %{"user" => user_params}) do | ||||||
|     case Accounts.reset_user_password(conn.assigns.user, user_params) do |     case Accounts.reset_user_password(conn.assigns.user, user_params) do | ||||||
|       {:ok, _} -> |       {:ok, _socket} -> | ||||||
|         conn |         conn | ||||||
|         |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) |         |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) | ||||||
|         |> redirect(to: ~p"/users/log_in") |         |> redirect(to: ~p"/users/log_in") | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||||
|   <h1 class="title text-primary-600 text-xl"> |   <h1 class="title text-primary-600 text-xl"> | ||||||
|     <%= dgettext("actions", "Reset password") %> |     {dgettext("actions", "Reset password")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -9,36 +9,36 @@ | |||||||
|     action={~p"/users/reset_password/#{@token}"} |     action={~p"/users/reset_password/#{@token}"} | ||||||
|     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" | ||||||
|   > |   > | ||||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> |     <p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3"> | ||||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> |       {dgettext("errors", "Oops, something went wrong! Please check the errors below.")} | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|     <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> |     {label(f, :password, gettext("New password"), class: "title text-lg text-primary-600")} | ||||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> |     {password_input(f, :password, required: true, class: "input input-primary col-span-2")} | ||||||
|     <%= error_tag(f, :password, "col-span-3") %> |     {error_tag(f, :password, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :password_confirmation, gettext("Confirm new password"), |     {label(f, :password_confirmation, gettext("Confirm new password"), | ||||||
|       class: "title text-lg text-primary-600" |       class: "title text-lg text-primary-600" | ||||||
|     ) %> |     )} | ||||||
|     <%= password_input(f, :password_confirmation, |     {password_input(f, :password_confirmation, | ||||||
|       required: true, |       required: true, | ||||||
|       class: "input input-primary col-span-2" |       class: "input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :password_confirmation, "col-span-3") %> |     {error_tag(f, :password_confirmation, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Reset password"), |     {submit(dgettext("actions", "Reset password"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3" |       class: "mx-auto btn btn-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-row justify-center items-center space-x-4"> |   <div class="flex flex-row justify-center items-center space-x-4"> | ||||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> |     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Register") %> |       {dgettext("actions", "Register")} | ||||||
|     </.link> |     </.link> | ||||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> |     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Log in") %> |       {dgettext("actions", "Log in")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||||
|   <h1 class="title text-primary-600 text-xl"> |   <h1 class="title text-primary-600 text-xl"> | ||||||
|     <%= dgettext("actions", "Forgot your password?") %> |     {dgettext("actions", "Forgot your password?")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -10,22 +10,22 @@ | |||||||
|     action={~p"/users/reset_password"} |     action={~p"/users/reset_password"} | ||||||
|     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" | ||||||
|   > |   > | ||||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> |     {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")} | ||||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> |     {email_input(f, :email, required: true, class: "input input-primary col-span-2")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Send instructions to reset password"), |     {submit(dgettext("actions", "Send instructions to reset password"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3" |       class: "mx-auto btn btn-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-row justify-center items-center space-x-4"> |   <div class="flex flex-row justify-center items-center space-x-4"> | ||||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> |     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Register") %> |       {dgettext("actions", "Register")} | ||||||
|     </.link> |     </.link> | ||||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> |     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Log in") %> |       {dgettext("actions", "Log in")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||||
|   <h1 class="title text-primary-600 text-xl"> |   <h1 class="title text-primary-600 text-xl"> | ||||||
|     <%= dgettext("actions", "Log in") %> |     {dgettext("actions", "Log in")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <.form |   <.form | ||||||
| @@ -11,31 +11,39 @@ | |||||||
|     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" | ||||||
|   > |   > | ||||||
|     <p :if={@error_message} class="alert alert-danger col-span-3"> |     <p :if={@error_message} class="alert alert-danger col-span-3"> | ||||||
|       <%= @error_message %> |       {@error_message} | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> |     {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")} | ||||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> |     {email_input(f, :email, | ||||||
|  |       autocomplete: :email, | ||||||
|  |       class: "input input-primary col-span-2", | ||||||
|  |       required: true | ||||||
|  |     )} | ||||||
|  |  | ||||||
|     <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> |     {label(f, :password, gettext("Password"), class: "title text-lg text-primary-600")} | ||||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> |     {password_input(f, :password, | ||||||
|  |       autocomplete: "current-password", | ||||||
|  |       class: "input input-primary col-span-2", | ||||||
|  |       required: true | ||||||
|  |     )} | ||||||
|  |  | ||||||
|     <%= label(f, :remember_me, gettext("Keep me logged in for 60 days"), |     {label(f, :remember_me, gettext("Keep me logged in for 60 days"), | ||||||
|       class: "title text-lg text-primary-600" |       class: "title text-lg text-primary-600" | ||||||
|     ) %> |     )} | ||||||
|     <%= checkbox(f, :remember_me, class: "checkbox col-span-2") %> |     {checkbox(f, :remember_me, class: "checkbox col-span-2")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3") %> |     {submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3")} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex flex-row justify-center items-center space-x-4"> |   <div class="flex flex-row justify-center items-center space-x-4"> | ||||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> |     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Register") %> |       {dgettext("actions", "Register")} | ||||||
|     </.link> |     </.link> | ||||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> |     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Forgot your password?") %> |       {dgettext("actions", "Forgot your password?")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| defmodule CanneryWeb.UserSettingsController do | defmodule CanneryWeb.UserSettingsController do | ||||||
|   use CanneryWeb, :controller |   use CanneryWeb, :controller | ||||||
|   import CanneryWeb.Gettext |  | ||||||
|   alias Cannery.Accounts |   alias Cannery.Accounts | ||||||
|   alias CanneryWeb.UserAuth |   alias CanneryWeb.UserAuth | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="mx-auto pb-8 max-w-3xl flex flex-col justify-center items-center text-right space-y-4"> | <div class="flex flex-col justify-center items-center pb-8 mx-auto space-y-4 max-w-3xl text-right"> | ||||||
|   <h1 class="pb-4 title text-primary-600 text-2xl text-center"> |   <h1 class="pb-4 text-2xl text-center title text-primary-600"> | ||||||
|     <%= gettext("Settings") %> |     {gettext("Settings")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
| @@ -9,40 +9,40 @@ | |||||||
|     :let={f} |     :let={f} | ||||||
|     for={@email_changeset} |     for={@email_changeset} | ||||||
|     action={~p"/users/settings"} |     action={~p"/users/settings"} | ||||||
|     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 justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4" | ||||||
|   > |   > | ||||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> |     <h3 class="col-span-3 text-lg text-center title text-primary-600"> | ||||||
|       <%= dgettext("actions", "Change email") %> |       {dgettext("actions", "Change email")} | ||||||
|     </h3> |     </h3> | ||||||
|  |  | ||||||
|     <div |     <div | ||||||
|       :if={@email_changeset.action && not @email_changeset.valid?()} |       :if={@email_changeset.action && not @email_changeset.valid?} | ||||||
|       class="alert alert-danger col-span-3" |       class="col-span-3 alert alert-danger" | ||||||
|     > |     > | ||||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> |       {dgettext("errors", "Oops, something went wrong! Please check the errors below.")} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= hidden_input(f, :action, name: "action", value: "update_email") %> |     {hidden_input(f, :action, name: "action", value: "update_email")} | ||||||
|  |  | ||||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> |     {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")} | ||||||
|     <%= email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2") %> |     {email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2")} | ||||||
|     <%= error_tag(f, :email, "col-span-3") %> |     {error_tag(f, :email, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :current_password, gettext("Current password"), |     {label(f, :current_password, gettext("Current password"), | ||||||
|       for: "current_password_for_email", |       for: "current_password_for_email", | ||||||
|       class: "mx-2 my-1 title text-lg text-primary-600" |       class: "mx-2 my-1 title text-lg text-primary-600" | ||||||
|     ) %> |     )} | ||||||
|     <%= password_input(f, :current_password, |     {password_input(f, :current_password, | ||||||
|       required: true, |       required: true, | ||||||
|       name: "current_password", |       name: "current_password", | ||||||
|       id: "current_password_for_email", |       id: "current_password_for_email", | ||||||
|       class: "mx-2 my-1 input input-primary col-span-2" |       class: "mx-2 my-1 input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :current_password, "col-span-3") %> |     {error_tag(f, :current_password, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Change email"), |     {submit(dgettext("actions", "Change email"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3" |       class: "mx-auto btn btn-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
| @@ -51,52 +51,52 @@ | |||||||
|     :let={f} |     :let={f} | ||||||
|     for={@password_changeset} |     for={@password_changeset} | ||||||
|     action={~p"/users/settings"} |     action={~p"/users/settings"} | ||||||
|     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 justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4" | ||||||
|   > |   > | ||||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> |     <h3 class="col-span-3 text-lg text-center title text-primary-600"> | ||||||
|       <%= dgettext("actions", "Change password") %> |       {dgettext("actions", "Change password")} | ||||||
|     </h3> |     </h3> | ||||||
|  |  | ||||||
|     <div |     <div | ||||||
|       :if={@password_changeset.action && not @password_changeset.valid?()} |       :if={@password_changeset.action && not @password_changeset.valid?} | ||||||
|       class="alert alert-danger col-span-3" |       class="col-span-3 alert alert-danger" | ||||||
|     > |     > | ||||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> |       {dgettext("errors", "Oops, something went wrong! Please check the errors below.")} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= hidden_input(f, :action, name: "action", value: "update_password") %> |     {hidden_input(f, :action, name: "action", value: "update_password")} | ||||||
|  |  | ||||||
|     <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> |     {label(f, :password, gettext("New password"), class: "title text-lg text-primary-600")} | ||||||
|     <%= password_input(f, :password, |     {password_input(f, :password, | ||||||
|       required: true, |       required: true, | ||||||
|       class: "mx-2 my-1 input input-primary col-span-2" |       class: "mx-2 my-1 input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :password, "col-span-3") %> |     {error_tag(f, :password, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :password_confirmation, gettext("Confirm new password"), |     {label(f, :password_confirmation, gettext("Confirm new password"), | ||||||
|       class: "title text-lg text-primary-600" |       class: "title text-lg text-primary-600" | ||||||
|     ) %> |     )} | ||||||
|     <%= password_input(f, :password_confirmation, |     {password_input(f, :password_confirmation, | ||||||
|       required: true, |       required: true, | ||||||
|       class: "mx-2 my-1 input input-primary col-span-2" |       class: "mx-2 my-1 input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :password_confirmation, "col-span-3") %> |     {error_tag(f, :password_confirmation, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= label(f, :current_password, gettext("Current password"), |     {label(f, :current_password, gettext("Current password"), | ||||||
|       for: "current_password_for_password", |       for: "current_password_for_password", | ||||||
|       class: "title text-lg text-primary-600" |       class: "title text-lg text-primary-600" | ||||||
|     ) %> |     )} | ||||||
|     <%= password_input(f, :current_password, |     {password_input(f, :current_password, | ||||||
|       required: true, |       required: true, | ||||||
|       name: "current_password", |       name: "current_password", | ||||||
|       id: "current_password_for_password", |       id: "current_password_for_password", | ||||||
|       class: "mx-2 my-1 input input-primary col-span-2" |       class: "mx-2 my-1 input input-primary col-span-2" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :current_password, "col-span-3") %> |     {error_tag(f, :current_password, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Change password"), |     {submit(dgettext("actions", "Change password"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3" |       class: "mx-auto btn btn-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
| @@ -105,45 +105,45 @@ | |||||||
|     :let={f} |     :let={f} | ||||||
|     for={@locale_changeset} |     for={@locale_changeset} | ||||||
|     action={~p"/users/settings"} |     action={~p"/users/settings"} | ||||||
|     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 justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4" | ||||||
|   > |   > | ||||||
|     <%= label(f, :locale, dgettext("actions", "Change Language"), |     {label(f, :locale, dgettext("actions", "Change Language"), | ||||||
|       class: "title text-primary-600 text-lg text-center col-span-3" |       class: "title text-primary-600 text-lg text-center col-span-3" | ||||||
|     ) %> |     )} | ||||||
|  |  | ||||||
|     <div |     <div | ||||||
|       :if={@locale_changeset.action && not @locale_changeset.valid?()} |       :if={@locale_changeset.action && not @locale_changeset.valid?} | ||||||
|       class="alert alert-danger col-span-3" |       class="col-span-3 alert alert-danger" | ||||||
|     > |     > | ||||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> |       {dgettext("errors", "Oops, something went wrong! Please check the errors below.")} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= hidden_input(f, :action, name: "action", value: "update_locale") %> |     {hidden_input(f, :action, name: "action", value: "update_locale")} | ||||||
|  |  | ||||||
|     <%= select( |     {select( | ||||||
|       f, |       f, | ||||||
|       :locale, |       :locale, | ||||||
|       [ |       [ | ||||||
|         {gettext("English"), "en_US"}, |         {"English", "en_US"}, | ||||||
|         {gettext("German"), "de"}, |         {"Deutsch", "de"}, | ||||||
|         {gettext("French"), "fr"}, |         {"Français", "fr"}, | ||||||
|         {gettext("Spanish"), "es"} |         {"Español", "es"} | ||||||
|       ], |       ], | ||||||
|       class: "mx-2 my-1 min-w-md input input-primary col-span-3" |       class: "my-1 min-w-20 input input-primary col-span-3" | ||||||
|     ) %> |     )} | ||||||
|     <%= error_tag(f, :locale, "col-span-3") %> |     {error_tag(f, :locale, "col-span-3")} | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Change language"), |     {submit(dgettext("actions", "Change language"), | ||||||
|       class: "whitespace-nowrap mx-auto btn btn-primary col-span-3", |       class: "whitespace-nowrap mx-auto btn btn-primary col-span-3", | ||||||
|       data: [qa: dgettext("prompts", "Are you sure you want to change your language?")] |       data: [qa: dgettext("prompts", "Are you sure you want to change your language?")] | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
|  |  | ||||||
|   <hr class="hr" /> |   <hr class="hr" /> | ||||||
|  |  | ||||||
|   <div class="flex justify-center items-center"> |   <div class="flex justify-center items-center"> | ||||||
|     <.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank"> |     <.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank"> | ||||||
|       <%= dgettext("actions", "Export Data as JSON") %> |       {dgettext("actions", "Export Data as JSON")} | ||||||
|     </.link> |     </.link> | ||||||
|  |  | ||||||
|     <.link |     <.link | ||||||
| @@ -152,7 +152,7 @@ | |||||||
|       class="mx-4 my-2 btn btn-alert" |       class="mx-4 my-2 btn btn-alert" | ||||||
|       data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")} |       data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")} | ||||||
|     > |     > | ||||||
|       <%= dgettext("actions", "Delete User") %> |       {dgettext("actions", "Delete User")} | ||||||
|     </.link> |     </.link> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ defmodule CanneryWeb.ErrorHelpers do | |||||||
|   Conveniences for translating and building error messages. |   Conveniences for translating and building error messages. | ||||||
|   """ |   """ | ||||||
|  |  | ||||||
|   use Phoenix.HTML |   use PhoenixHTMLHelpers | ||||||
|   import Phoenix.Component |   import Phoenix.Component | ||||||
|   alias Ecto.Changeset |   alias Ecto.Changeset | ||||||
|   alias Phoenix.{HTML.Form, LiveView.Rendered} |   alias Phoenix.{HTML.Form, LiveView.Rendered} | ||||||
| @@ -19,10 +19,10 @@ defmodule CanneryWeb.ErrorHelpers do | |||||||
|     ~H""" |     ~H""" | ||||||
|     <span |     <span | ||||||
|       :for={error <- Keyword.get_values(@form.errors, @field)} |       :for={error <- Keyword.get_values(@form.errors, @field)} | ||||||
|  |       :if={used_input?(@form[@field])} | ||||||
|       class={["invalid-feedback", @extra_class]} |       class={["invalid-feedback", @extra_class]} | ||||||
|       phx-feedback-for={input_name(@form, @field)} |  | ||||||
|     > |     > | ||||||
|       <%= translate_error(error) %> |       {translate_error(error)} | ||||||
|     </span> |     </span> | ||||||
|     """ |     """ | ||||||
|   end |   end | ||||||
| @@ -65,7 +65,7 @@ defmodule CanneryWeb.ErrorHelpers do | |||||||
|     changeset |     changeset | ||||||
|     |> changeset_error_map() |     |> changeset_error_map() | ||||||
|     |> Enum.map_join(". ", fn {key, errors} -> |     |> Enum.map_join(". ", fn {key, errors} -> | ||||||
|       "#{key |> humanize()}: #{errors |> Enum.join(", ")}" |       "#{key |> Phoenix.Naming.humanize()}: #{errors |> Enum.join(", ")}" | ||||||
|     end) |     end) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ defmodule CanneryWeb.Gettext do | |||||||
|   By using [Gettext](https://hexdocs.pm/gettext), |   By using [Gettext](https://hexdocs.pm/gettext), | ||||||
|   your module gains a set of macros for translations, for example: |   your module gains a set of macros for translations, for example: | ||||||
|  |  | ||||||
|       import CanneryWeb.Gettext |       use Gettext, backend: CanneryWeb.Gettext | ||||||
|  |  | ||||||
|       # Simple translation |       # Simple translation | ||||||
|       gettext("Here is the string to translate") |       gettext("Here is the string to translate") | ||||||
| @@ -20,5 +20,5 @@ defmodule CanneryWeb.Gettext do | |||||||
|  |  | ||||||
|   See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. |   See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. | ||||||
|   """ |   """ | ||||||
|   use Gettext, otp_app: :cannery |   use Gettext.Backend, otp_app: :cannery | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="flex flex-col justify-center items-center text-center space-y-8"> | <div class="flex flex-col justify-center items-center text-center space-y-8"> | ||||||
|   <h2 class="title text-xl text-primary-600"> |   <h2 class="title text-xl text-primary-600"> | ||||||
|     <%= @title %> |     {@title} | ||||||
|   </h2> |   </h2> | ||||||
|  |  | ||||||
|   <div class="flex flex-wrap justify-center items-center"> |   <div class="flex flex-wrap justify-center items-center"> | ||||||
| @@ -21,13 +21,13 @@ | |||||||
|         ) |         ) | ||||||
|       } |       } | ||||||
|     > |     > | ||||||
|       <%= 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> | ||||||
|  |  | ||||||
|     <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600"> |     <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600"> | ||||||
|       <%= gettext("No tags") %> |       {gettext("No tags")} | ||||||
|       <%= display_emoji("😔") %> |       {display_emoji("😔")} | ||||||
|     </h2> |     </h2> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
| @@ -43,15 +43,18 @@ | |||||||
|       phx-target={@myself} |       phx-target={@myself} | ||||||
|       phx-submit="save" |       phx-submit="save" | ||||||
|     > |     > | ||||||
|       <%= select(f, :tag_id, tag_options(@tags, @container), |       {select(f, :tag_id, tag_options(@tags, @container), | ||||||
|         class: "text-center col-span-2 input input-primary" |         class: "text-center col-span-2 input input-primary", | ||||||
|       ) %> |         id: "#{@id}-tag-select", | ||||||
|       <%= error_tag(f, :tag_id, "col-span-3 text-center") %> |         phx_hook: "SlimSelect", | ||||||
|  |         phx_update: :ignore | ||||||
|  |       )} | ||||||
|  |       {error_tag(f, :tag_id, "col-span-3 text-center")} | ||||||
|  |  | ||||||
|       <%= submit(dgettext("actions", "Add"), |       {submit(dgettext("actions", "Add"), | ||||||
|         class: "mx-auto btn btn-primary", |         class: "mx-auto btn btn-primary", | ||||||
|         phx_disable_with: dgettext("prompts", "Adding...") |         phx_disable_with: dgettext("prompts", "Adding...") | ||||||
|       ) %> |       )} | ||||||
|     </.form> |     </.form> | ||||||
|   <% end %> |   <% end %> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | |||||||
|  |  | ||||||
|   @impl true |   @impl true | ||||||
|   def handle_event("validate", %{"container" => container_params}, socket) do |   def handle_event("validate", %{"container" => container_params}, socket) do | ||||||
|     {:noreply, socket |> assign_changeset(container_params)} |     {:noreply, socket |> assign_changeset(container_params, :validate)} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def handle_event( |   def handle_event( | ||||||
| @@ -32,14 +32,9 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | |||||||
|  |  | ||||||
|   defp assign_changeset( |   defp assign_changeset( | ||||||
|          %{assigns: %{action: action, container: container, current_user: user}} = socket, |          %{assigns: %{action: action, container: container, current_user: user}} = socket, | ||||||
|          container_params |          container_params, | ||||||
|  |          changeset_action \\ nil | ||||||
|        ) do |        ) do | ||||||
|     changeset_action = |  | ||||||
|       case action do |  | ||||||
|         create when create in [:new, :clone] -> :insert |  | ||||||
|         :edit -> :update |  | ||||||
|       end |  | ||||||
|  |  | ||||||
|     changeset = |     changeset = | ||||||
|       case action do |       case action do | ||||||
|         create when create in [:new, :clone] -> |         create when create in [:new, :clone] -> | ||||||
| @@ -50,10 +45,14 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | |||||||
|       end |       end | ||||||
|  |  | ||||||
|     changeset = |     changeset = | ||||||
|  |       if changeset_action do | ||||||
|         case changeset |> Changeset.apply_action(changeset_action) do |         case changeset |> Changeset.apply_action(changeset_action) do | ||||||
|           {:ok, _data} -> changeset |           {:ok, _data} -> changeset | ||||||
|           {:error, changeset} -> changeset |           {:error, changeset} -> changeset | ||||||
|         end |         end | ||||||
|  |       else | ||||||
|  |         changeset | ||||||
|  |       end | ||||||
|  |  | ||||||
|     socket |> assign(:changeset, changeset) |     socket |> assign(:changeset, changeset) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div> | <div> | ||||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> |   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||||
|     <%= @title %> |     {@title} | ||||||
|   </h2> |   </h2> | ||||||
|   <.form |   <.form | ||||||
|     :let={f} |     :let={f} | ||||||
| @@ -12,51 +12,53 @@ | |||||||
|     phx-submit="save" |     phx-submit="save" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       :if={@changeset.action && not @changeset.valid?()} |       :if={@changeset.action && not @changeset.valid?} | ||||||
|       class="invalid-feedback col-span-3 text-center" |       class="invalid-feedback col-span-3 text-center" | ||||||
|     > |     > | ||||||
|       <%= changeset_errors(@changeset) %> |       {changeset_errors(@changeset)} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= 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"), |       maxlength: 255, | ||||||
|       maxlength: 255 |       phx_debounce: 300, | ||||||
|     ) %> |       placeholder: gettext("My cool ammo can") | ||||||
|     <%= 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, | ||||||
|  |       class: "input input-primary col-span-2", | ||||||
|       id: "container-form-desc", |       id: "container-form-desc", | ||||||
|       class: "input input-primary col-span-2", |       phx_debounce: 300, | ||||||
|       placeholder: gettext("Metal ammo can with the anime girl sticker"), |       phx_update: "ignore", | ||||||
|       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"), |       maxlength: 255, | ||||||
|       maxlength: 255 |       phx_debounce: 300, | ||||||
|     ) %> |       placeholder: gettext("Magazine, Clip, Ammo Box, etc") | ||||||
|     <%= 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, | ||||||
|  |       class: "input input-primary col-span-2", | ||||||
|       id: "container-form-location", |       id: "container-form-location", | ||||||
|       class: "input input-primary col-span-2", |       phx_debounce: 300, | ||||||
|       placeholder: gettext("On the bookshelf"), |       phx_update: "ignore", | ||||||
|       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") %> |  | ||||||
|  |  | ||||||
|     <%= submit(dgettext("actions", "Save"), |     {submit(dgettext("actions", "Save"), | ||||||
|       class: "mx-auto btn btn-primary col-span-3", |       class: "mx-auto btn btn-primary col-span-3", | ||||||
|       phx_disable_with: dgettext("prompts", "Saving...") |       phx_disable_with: dgettext("prompts", "Saving...") | ||||||
|     ) %> |     )} | ||||||
|   </.form> |   </.form> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -112,7 +112,21 @@ defmodule CanneryWeb.ContainerLive.Index do | |||||||
|     {:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")} |     {:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")} | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def handle_event( | ||||||
|  |         "toggle_staged", | ||||||
|  |         %{"container_id" => id}, | ||||||
|  |         %{assigns: %{current_user: current_user}} = socket | ||||||
|  |       ) do | ||||||
|  |     container = Containers.get_container!(id, current_user) | ||||||
|  |  | ||||||
|  |     {:ok, _container} = | ||||||
|  |       container | ||||||
|  |       |> Containers.update_container(current_user, %{"staged" => !container.staged}) | ||||||
|  |  | ||||||
|  |     {:noreply, socket |> display_containers()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do |   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||||
|     socket |> assign(:containers, Containers.list_containers(search, current_user)) |     socket |> assign(:containers, Containers.list_containers(current_user, search: search)) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,61 +1,75 @@ | |||||||
| <div class="flex flex-col space-y-8 justify-center items-center"> | <div class="flex flex-col justify-center items-center space-y-8"> | ||||||
|   <h1 class="title text-2xl title-primary-500"> |   <h1 class="text-2xl title title-primary-500"> | ||||||
|     <%= gettext("Containers") %> |     {gettext("Containers")} | ||||||
|   </h1> |   </h1> | ||||||
|  |  | ||||||
|   <%= if @containers |> Enum.empty?() and @search |> is_nil() do %> |   <%= if @containers |> Enum.empty?() and @search |> is_nil() do %> | ||||||
|     <h2 class="title text-xl text-primary-600"> |     <h2 class="text-xl title text-primary-600"> | ||||||
|       <%= gettext("No containers") %> |       {gettext("No containers")} | ||||||
|       <%= display_emoji("😔") %> |       {display_emoji("😔")} | ||||||
|     </h2> |     </h2> | ||||||
|  |  | ||||||
|     <.link patch={~p"/containers/new"} class="btn btn-primary"> |     <.link patch={~p"/containers/new"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "Add your first container!") %> |       {dgettext("actions", "Add your first container!")} | ||||||
|     </.link> |     </.link> | ||||||
|   <% else %> |   <% else %> | ||||||
|     <.link patch={~p"/containers/new"} class="btn btn-primary"> |     <.link patch={~p"/containers/new"} class="btn btn-primary"> | ||||||
|       <%= dgettext("actions", "New Container") %> |       {dgettext("actions", "New Container")} | ||||||
|     </.link> |     </.link> | ||||||
|  |  | ||||||
|     <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"> |     <div class="flex flex-col flex-wrap justify-center items-center space-y-4 w-full sm:flex-row sm:space-y-0 sm:space-x-4"> | ||||||
|       <.form |       <.form | ||||||
|         :let={f} |         :let={f} | ||||||
|         for={%{}} |         for={%{}} | ||||||
|         as={:search} |         as={:search} | ||||||
|         phx-change="search" |         phx-change="search" | ||||||
|         phx-submit="search" |         phx-submit="search" | ||||||
|         class="grow flex items-center" |         class="flex items-center grow" | ||||||
|       > |       > | ||||||
|         <%= text_input(f, :search_term, |         {text_input(f, :search_term, | ||||||
|           class: "grow input input-primary", |           class: "grow input input-primary", | ||||||
|           value: @search, |  | ||||||
|           role: "search", |  | ||||||
|           phx_debounce: 300, |           phx_debounce: 300, | ||||||
|           placeholder: gettext("Search containers") |           placeholder: gettext("Search containers"), | ||||||
|         ) %> |           role: "search", | ||||||
|  |           value: @search | ||||||
|  |         )} | ||||||
|       </.form> |       </.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="text-lg title text-primary-600"> | ||||||
|           <%= gettext("View as table") %> |           {gettext("View as table")} | ||||||
|         </span> |         </span> | ||||||
|       </.toggle_button> |       </.toggle_button> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <%= if @containers |> Enum.empty?() do %> |     <%= if @containers |> Enum.empty?() do %> | ||||||
|       <h2 class="title text-xl text-primary-600"> |       <h2 class="text-xl title text-primary-600"> | ||||||
|         <%= gettext("No containers") %> |         {gettext("No containers")} | ||||||
|         <%= display_emoji("😔") %> |         {display_emoji("😔")} | ||||||
|       </h2> |       </h2> | ||||||
|     <% else %> |     <% else %> | ||||||
|       <%= if @view_table do %> |       <%= if @view_table do %> | ||||||
|         <.live_component |         <.live_component | ||||||
|           module={CanneryWeb.Components.ContainerTableComponent} |           module={CanneryWeb.Components.ContainerTableComponent} | ||||||
|           id="containers_index_table" |           id="containers-index-table" | ||||||
|           action={@live_action} |           action={@live_action} | ||||||
|           containers={@containers} |           containers={@containers} | ||||||
|           current_user={@current_user} |           current_user={@current_user} | ||||||
|         > |         > | ||||||
|  |           <:range :let={container}> | ||||||
|  |             <div class="flex flex-wrap justify-center items-center px-4 py-2 h-full min-w-20"> | ||||||
|  |               <button | ||||||
|  |                 type="button" | ||||||
|  |                 class="mx-2 my-1 text-sm btn btn-primary" | ||||||
|  |                 phx-click="toggle_staged" | ||||||
|  |                 phx-value-container_id={container.id} | ||||||
|  |               > | ||||||
|  |                 {if container.staged, | ||||||
|  |                   do: dgettext("actions", "Unstage"), | ||||||
|  |                   else: dgettext("actions", "Stage")} | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </:range> | ||||||
|           <:tag_actions :let={container}> |           <:tag_actions :let={container}> | ||||||
|             <div class="mx-4 my-2"> |             <div class="mx-4 my-2"> | ||||||
|               <.link |               <.link | ||||||
| @@ -109,7 +123,7 @@ | |||||||
|           </:actions> |           </:actions> | ||||||
|         </.live_component> |         </.live_component> | ||||||
|       <% else %> |       <% else %> | ||||||
|         <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> |         <div class="flex flex-row flex-wrap justify-center items-stretch w-full"> | ||||||
|           <.container_card |           <.container_card | ||||||
|             :for={container <- @containers} |             :for={container <- @containers} | ||||||
|             container={container} |             container={container} | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user