Compare commits
	
		
			124 Commits
		
	
	
		
			0.5.4
			...
			9c4a32896f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9c4a32896f | |||
| c8cadd6246 | |||
| 3f143262d4 | |||
| 42e2d1c76e | |||
| 064d2d3988 | |||
| f6b5fc17fa | |||
| 2c0a4dd7ca | |||
| 47dab6490d | |||
| 084173909e | |||
| 2f8af8ae4f | |||
| 2cf705c46f | |||
| 725df05521 | |||
| 3ae890c193 | |||
| 75fcbb1e65 | |||
| e568a2f073 | |||
| 0b27c8f80b | |||
| 0ad1ee47de | |||
| 8ea2b06487 | |||
| 1e4accec9d | |||
| 076d5eea18 | |||
| 5b6bd00047 | |||
| 8dd471afa8 | |||
| 09d3754f92 | |||
| fa67fd5a3b | |||
| 0a56b32b17 | |||
| 5f214ab7f9 | |||
| 0c95fa88f4 | |||
| 61f8776d2e | |||
| 26f75c7d15 | |||
| 23f7af2a93 | |||
| 8e771a2d9a | |||
| 16155687c9 | |||
| ef0d644ee3 | |||
| faacf80b51 | |||
| 7a7754767f | |||
| 0894293620 | |||
| 58b32817ba | |||
| 45da547f62 | |||
| 3ea4b77b67 | |||
| d743336868 | |||
| c99fbabcaa | |||
| 95642061db | |||
| 11ef53d1bf | |||
| 757eca47f7 | |||
| 7191fe8e4b | |||
|  | 02253c74ac | ||
|  | fa32b2a073 | ||
|  | aa0ea10fa4 | ||
|  | 72ac611e15 | ||
|  | ff0af84d5d | ||
|  | 851d0954ad | ||
|  | ea4336adc5 | ||
|  | fcce6a6f40 | ||
|  | 4d3085da2a | ||
| 2ec60ba342 | |||
| e0ac714681 | |||
| 20a2311229 | |||
| a6ae8a872d | |||
| 81e448afc4 | |||
| dfebad713f | |||
| bf0ea4168b | |||
| 5ffa627beb | |||
| 25d4f1916a | |||
| fd583f2941 | |||
| 5cae646758 | |||
| d389515f76 | |||
| c2568b6521 | |||
| 60462877c5 | |||
| ba5a4e69b2 | |||
|  | ba36b4e4c5 | ||
|  | f5a092e91f | ||
| 09774e0aed | |||
| 44d4d7c6f9 | |||
| 1fed895b82 | |||
| 3480aff61d | |||
| 1ea4b99c81 | |||
| 2191e7f4f4 | |||
| 8231683958 | |||
| a3e7e3a86d | |||
| 0c088ed06d | |||
| 1c7721887f | |||
| 0dbd1af553 | |||
| c828def2b2 | |||
| 7ef582510e | |||
| 726ede7e46 | |||
| 8fa75e2559 | |||
| 0d6f6de7df | |||
| 76d3554b4b | |||
| 09394ea408 | |||
| 3dd6430105 | |||
| 53bb0ddb03 | |||
| e3ff04ced3 | |||
| db4b3fab24 | |||
| d86f5a6d98 | |||
| 9420337228 | |||
| e2a4dc4b92 | |||
| 175eef95fb | |||
| ba8d7988b3 | |||
| 1f017ced4a | |||
| 67304ae22e | |||
| 30da5bc4f7 | |||
| 580d703f12 | |||
| cb4de9a6ff | |||
| ca4b5b9822 | |||
| 13bf8ab2b1 | |||
| ffd1029e78 | |||
| 1d8f3360d8 | |||
| 6b565abf93 | |||
| 239b15a782 | |||
| cfbec3189c | |||
| cc31958bbe | |||
| 9e386f1631 | |||
| 36a0a1c6c8 | |||
| 2e0bb861f1 | |||
| dbc575d305 | |||
| 5390dcc4c2 | |||
| dc355fcd8e | |||
| 44fbd69e0f | |||
| 10c4f40864 | |||
| d512ec8e10 | |||
| 81b68ba4af | |||
| b6e0a543ec | |||
| cf75007e4d | |||
| 1583ad3503 | 
							
								
								
									
										35
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -13,28 +13,36 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| - name: test | ||||
|   image: elixir:1.13.4-alpine | ||||
|   image: elixir:1.14.1-alpine | ||||
|   environment: | ||||
|     TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test | ||||
|     HOST: testing.example.tld | ||||
|     MIX_HOME: /drone/src/.mix | ||||
|     MIX_ARCHIVES: /drone/src/.mix/archives | ||||
|     MIX_ENV: test | ||||
|   commands: | ||||
|   - apk add --no-cache build-base npm git python3 | ||||
|   - mix local.rebar --force | ||||
|   - mix local.hex --force | ||||
|   - apk add --no-cache build-base npm git | ||||
|   - mix local.rebar --force --if-missing | ||||
|   - mix local.hex --force --if-missing | ||||
|   - mix deps.get | ||||
|   - mix deps.compile | ||||
|   - npm --prefix ./assets ci --progress=false --no-audit --loglevel=error | ||||
|   - npm set cache .npm | ||||
|   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||
|   - npm run --prefix ./assets deploy | ||||
|   - mix do phx.digest, gettext.extract | ||||
|   - mix test | ||||
|   - mix test.all | ||||
|  | ||||
| - name: build and publish stable | ||||
|   image: plugins/docker | ||||
|   image: thegeeklab/drone-docker-buildx | ||||
|   privileged: true | ||||
|   settings: | ||||
|     repo: shibaobun/cannery | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: linux/amd64,linux/arm/v7 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -45,9 +53,13 @@ steps: | ||||
|     - stable | ||||
|  | ||||
| - name: build and publish tagged version | ||||
|   image: plugins/docker | ||||
|   image: thegeeklab/drone-docker-buildx | ||||
|   privileged: true | ||||
|   settings: | ||||
|     repo: shibaobun/cannery | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: linux/amd64,linux/arm/v7 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -68,7 +80,8 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| services: | ||||
| - name: database | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| elixir 1.13.4-otp-24 | ||||
| erlang 24.2 | ||||
| nodejs 16.13.2 | ||||
| elixir 1.14.1-otp-25 | ||||
| erlang 25.1.2 | ||||
| nodejs 18.9.1 | ||||
|   | ||||
							
								
								
									
										65
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										65
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,3 +1,68 @@ | ||||
| # v0.8.2 | ||||
| - Fix bug with public registration | ||||
| - Improve templates | ||||
| - Improve invites, record usage | ||||
| - Fix padding on more pages when using chrome | ||||
| - Add oban metrics to server log and live dashboard | ||||
|  | ||||
| # v0.8.1 | ||||
| - Update dependencies | ||||
| - Show topbar on form submit/page refresh | ||||
| - Make loading/reconnection less intrusive | ||||
| - Add QR code for invite link | ||||
|  | ||||
| # v0.8.0 | ||||
| - Add search to catalog, ammo, container, tag and range index pages | ||||
| - Tweak urls for catalog, ammo, containers, tags and shot records | ||||
| - Fix bug with shot group chart not drawing lines between days correctly | ||||
| - Improve cards across app (make them line up with each other) | ||||
| - Update translations and add spanish!!! (thank you Brea and Hannah!) | ||||
|  | ||||
| # v0.7.2 | ||||
| - Code improvements | ||||
|  | ||||
| # v0.7.1 | ||||
| - Fix table component alignment and styling | ||||
| - Fix toggle button styling | ||||
| - Miscellanous code improvements | ||||
| - Improve container index table | ||||
| - Fix bug with ammo not updating after deleting shot group | ||||
| - Replace ammo "added on" with "purchased on" | ||||
| - Miscellaneous wording improvements | ||||
| - Update translations | ||||
|  | ||||
| # v0.7.0 | ||||
| - Add shading to table component | ||||
| - Fix chart to sum by day | ||||
| - Fix whitespace when copying invite url | ||||
| - Make ammo type show page also display ammo groups as table | ||||
| - Make container show page also display ammo groups as table | ||||
| - Display CPR for ammo packs | ||||
| - Add original count for ammo packs | ||||
| - Add ammo pack CPR and original count to json export | ||||
|  | ||||
| # v0.6.0 | ||||
| - Update translations | ||||
| - Display used-up date on used-up ammo | ||||
| - Make ammo index page a bit more compact | ||||
| - Make ammo index page filter used-up ammo | ||||
| - Make ammo catalog page include ammo count | ||||
| - Make ammo type show page a bit more compact | ||||
| - Make ammo type show page include container names for each ammo | ||||
| - Make ammo type show page filter used-up ammo | ||||
| - Make container index page optionally display a table | ||||
| - Make container show page a bit more compact | ||||
| - Make container show page filter used-up ammo | ||||
| - Forgot to add the logo as the favicon whoops | ||||
| - Add graph to range page | ||||
| - Add JSON export of data | ||||
| - Add ammo cloning | ||||
| - Add ammo type cloning | ||||
| - Add container cloning | ||||
| - Fix bug with moving ammo packs between containers | ||||
| - Add button to set rounds left to 0 when creating a shot group | ||||
| - Update project dependencies | ||||
|  | ||||
| # v0.5.4 | ||||
| - Rename "Ammo" tab to "Catalog", and "Manage" tab is now "Ammo" | ||||
| - Ammo groups are now just referred to as Ammo or "Packs" | ||||
|   | ||||
| @@ -17,8 +17,8 @@ If you're multilingual, this project can use your translations! Visit | ||||
|   functions as short as possible while keeping variable names descriptive! For | ||||
|   instance, use inline `do:` blocks for short functions and make your aliases as | ||||
|   short as possible without introducing ambiguity. | ||||
|   - I.e. since there's only one `Changeset` in the app, please alias | ||||
|     `Changeset.t(Type.t())` instead of using `Ecto.Changeset.t(Long.Type.t())` | ||||
|   - I.e. since there's only one `AmmoGroup` in the app, please alias | ||||
|     `AmmoGroup.t()` instead of using `Cannery.Ammo.AmmoGroup.t()` | ||||
| - Use pipelines when possible. If only calling a single method, a pipeline isn't | ||||
|   strictly necessary but still encouraged for future modification. | ||||
| - Please add typespecs to your functions! Even your private functions may be | ||||
| @@ -144,3 +144,4 @@ Thank you so much for your contributions! | ||||
| - kaia (https://shitposter.club/users/kaia) | ||||
| - duponin (https://udongein.xyz/users/duponin) | ||||
| - kalli (https://twitter.com/t0kkuro) | ||||
| - brea (https://refusal.biz/users/tarperfume) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM elixir:1.13.4-alpine AS build | ||||
| FROM elixir:1.14.1-alpine AS build | ||||
|  | ||||
| # install build dependencies | ||||
| RUN apk add --no-cache build-base npm git python3 | ||||
| @@ -37,7 +37,7 @@ RUN mix do compile, release | ||||
| FROM alpine:latest AS app | ||||
|  | ||||
| RUN apk upgrade --no-cache && \ | ||||
|     apk add --no-cache bash openssl libgcc libstdc++ ncurses-libs | ||||
|     apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Cannery | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| The self-hosted firearm tracker website. | ||||
|   | ||||
| @@ -25,12 +25,13 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; | ||||
|   100% { scale: 1.0; opacity: 1; } | ||||
| } | ||||
|  | ||||
| .phx-connected > #disconnect, #loading { | ||||
| // disconnect toast | ||||
| .phx-connected > #disconnect { | ||||
|   opacity: 0 !important; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect { | ||||
| .phx-error > #disconnect { | ||||
|   opacity: 0.95 !important; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -25,7 +25,7 @@ | ||||
|   } | ||||
|  | ||||
|   .btn { | ||||
|     @apply inline-block break-words min-w-4; | ||||
|     @apply inline-block break-words; | ||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; | ||||
|     @apply shadow-sm focus:shadow-lg; | ||||
|     @apply transition-all duration-300 ease-in-out; | ||||
| @@ -52,7 +52,7 @@ | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     @apply inline-block break-all min-w-4; | ||||
|     @apply inline-block break-words; | ||||
|     @apply hover:underline; | ||||
|     @apply transition-colors duration-500 ease-in-out; | ||||
|   } | ||||
|   | ||||
| @@ -24,8 +24,9 @@ import 'phoenix_html' | ||||
| // Establish Phoenix Socket and LiveView configuration. | ||||
| import { Socket } from 'phoenix' | ||||
| import { LiveSocket } from 'phoenix_live_view' | ||||
| import topbar from '../vendor/topbar' | ||||
| import topbar from 'topbar' | ||||
| import MaintainAttrs from './maintain_attrs' | ||||
| import ShotLogChart from './shot_log_chart' | ||||
| import Alpine from 'alpinejs' | ||||
|  | ||||
| const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') | ||||
| @@ -36,7 +37,7 @@ const liveSocket = new LiveSocket('/live', Socket, { | ||||
|     } | ||||
|   }, | ||||
|   params: { _csrf_token: csrfToken }, | ||||
|   hooks: { MaintainAttrs } | ||||
|   hooks: { MaintainAttrs, ShotLogChart } | ||||
| }) | ||||
|  | ||||
| // alpine.js | ||||
| @@ -47,6 +48,8 @@ Alpine.start() | ||||
| topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) | ||||
| window.addEventListener('phx:page-loading-start', info => topbar.show()) | ||||
| window.addEventListener('phx:page-loading-stop', info => topbar.hide()) | ||||
| window.addEventListener('submit', info => topbar.show()) | ||||
| window.addEventListener('beforeunload', info => topbar.show()) | ||||
|  | ||||
| // connect if there are any LiveViews on the page | ||||
| liveSocket.connect() | ||||
| @@ -66,3 +69,8 @@ window.addEventListener('cannery:clipcopy', (event) => { | ||||
|     window.alert('Sorry, your browser does not support clipboard copy.') | ||||
|   } | ||||
| }) | ||||
|  | ||||
| // Set input value to 0 | ||||
| window.addEventListener('cannery:set-zero', (event) => { | ||||
|   event.target.value = 0 | ||||
| }) | ||||
|   | ||||
							
								
								
									
										106
									
								
								assets/js/shot_log_chart.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								assets/js/shot_log_chart.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js' | ||||
| import 'chartjs-adapter-date-fns' | ||||
| Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale) | ||||
|  | ||||
| export default { | ||||
|   initalizeChart (el) { | ||||
|     const data = JSON.parse(el.dataset.chartData) | ||||
|  | ||||
|     this.el.chart = new Chart(el, { | ||||
|       type: 'line', | ||||
|       data: { | ||||
|         datasets: [{ | ||||
|           label: el.dataset.label, | ||||
|           data: data.map(({ date, count, label }) => ({ | ||||
|             label, | ||||
|             x: date, | ||||
|             y: count | ||||
|           })), | ||||
|           backgroundColor: `${el.dataset.color}77`, | ||||
|           borderColor: el.dataset.color, | ||||
|           fill: true, | ||||
|           borderWidth: 3, | ||||
|           pointBorderWidth: 1 | ||||
|         }] | ||||
|       }, | ||||
|       options: { | ||||
|         elements: { | ||||
|           point: { | ||||
|             radius: 9, | ||||
|             hoverRadius: 12 | ||||
|           } | ||||
|         }, | ||||
|         plugins: { | ||||
|           legend: { | ||||
|             position: 'bottom', | ||||
|             labels: { | ||||
|               padding: 20 | ||||
|             } | ||||
|           }, | ||||
|           tooltip: { | ||||
|             displayColors: false, | ||||
|             callbacks: { | ||||
|               title: (contexts) => contexts.map(({ raw: { x } }) => Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }).format(new Date(x))), | ||||
|               label: ({ raw: { label } }) => label | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         scales: { | ||||
|           y: { | ||||
|             beginAtZero: true, | ||||
|             stacked: true, | ||||
|             grace: '15%', | ||||
|             ticks: { | ||||
|               padding: 15 | ||||
|             } | ||||
|           }, | ||||
|           x: { | ||||
|             type: 'time', | ||||
|             time: { | ||||
|               unit: 'day' | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         transitions: { | ||||
|           show: { | ||||
|             animations: { | ||||
|               x: { | ||||
|                 from: 0 | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           hide: { | ||||
|             animations: { | ||||
|               x: { | ||||
|                 to: 0 | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }, | ||||
|   updateChart (el) { | ||||
|     const data = JSON.parse(el.dataset.chartData) | ||||
|  | ||||
|     this.el.chart.data = { | ||||
|       datasets: [{ | ||||
|         label: el.dataset.label, | ||||
|         data: data.map(({ date, count, label }) => ({ | ||||
|           label, | ||||
|           x: date, | ||||
|           y: count | ||||
|         })), | ||||
|         backgroundColor: `${el.dataset.color}77`, | ||||
|         borderColor: el.dataset.color, | ||||
|         fill: true, | ||||
|         borderWidth: 3, | ||||
|         pointBorderWidth: 1 | ||||
|       }] | ||||
|     } | ||||
|  | ||||
|     this.el.chart.update() | ||||
|   }, | ||||
|   mounted () { this.initalizeChart(this.el) }, | ||||
|   updated () { this.updateChart(this.el) } | ||||
| } | ||||
							
								
								
									
										11005
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11005
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,6 +2,10 @@ | ||||
|   "repository": {}, | ||||
|   "description": " ", | ||||
|   "license": "MIT", | ||||
|   "engines": { | ||||
|     "node": "v18.9.1", | ||||
|     "npm": "8.10.0" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "NODE_ENV=production webpack --mode production", | ||||
|     "watch": "webpack --mode development --watch --watch-options-stdin", | ||||
| @@ -11,6 +15,9 @@ | ||||
|   "dependencies": { | ||||
|     "@fortawesome/fontawesome-free": "^6.1.1", | ||||
|     "alpinejs": "^3.10.2", | ||||
|     "chart.js": "^3.9.1", | ||||
|     "chartjs-adapter-date-fns": "^2.0.0", | ||||
|     "date-fns": "^2.29.3", | ||||
|     "phoenix": "file:../deps/phoenix", | ||||
|     "phoenix_html": "file:../deps/phoenix_html", | ||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", | ||||
| @@ -30,6 +37,7 @@ | ||||
|     "postcss-import": "^14.1.0", | ||||
|     "postcss-loader": "^6.2.1", | ||||
|     "postcss-preset-env": "^7.5.0", | ||||
|     "sass": "^1.56.0", | ||||
|     "sass-loader": "^12.6.0", | ||||
|     "standard": "^17.0.0", | ||||
|     "tailwindcss": "^3.0.24", | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 14 KiB | 
							
								
								
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,157 +0,0 @@ | ||||
| /** | ||||
|  * @license MIT | ||||
|  * topbar 1.0.0, 2021-01-06 | ||||
|  * https://buunguyen.github.io/topbar | ||||
|  * Copyright (c) 2021 Buu Nguyen | ||||
|  */ | ||||
| (function (window, document) { | ||||
|   "use strict"; | ||||
|  | ||||
|   // https://gist.github.com/paulirish/1579671 | ||||
|   (function () { | ||||
|     var lastTime = 0; | ||||
|     var vendors = ["ms", "moz", "webkit", "o"]; | ||||
|     for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { | ||||
|       window.requestAnimationFrame = | ||||
|         window[vendors[x] + "RequestAnimationFrame"]; | ||||
|       window.cancelAnimationFrame = | ||||
|         window[vendors[x] + "CancelAnimationFrame"] || | ||||
|         window[vendors[x] + "CancelRequestAnimationFrame"]; | ||||
|     } | ||||
|     if (!window.requestAnimationFrame) | ||||
|       window.requestAnimationFrame = function (callback, element) { | ||||
|         var currTime = new Date().getTime(); | ||||
|         var timeToCall = Math.max(0, 16 - (currTime - lastTime)); | ||||
|         var id = window.setTimeout(function () { | ||||
|           callback(currTime + timeToCall); | ||||
|         }, timeToCall); | ||||
|         lastTime = currTime + timeToCall; | ||||
|         return id; | ||||
|       }; | ||||
|     if (!window.cancelAnimationFrame) | ||||
|       window.cancelAnimationFrame = function (id) { | ||||
|         clearTimeout(id); | ||||
|       }; | ||||
|   })(); | ||||
|  | ||||
|   var canvas, | ||||
|     progressTimerId, | ||||
|     fadeTimerId, | ||||
|     currentProgress, | ||||
|     showing, | ||||
|     addEvent = function (elem, type, handler) { | ||||
|       if (elem.addEventListener) elem.addEventListener(type, handler, false); | ||||
|       else if (elem.attachEvent) elem.attachEvent("on" + type, handler); | ||||
|       else elem["on" + type] = handler; | ||||
|     }, | ||||
|     options = { | ||||
|       autoRun: true, | ||||
|       barThickness: 3, | ||||
|       barColors: { | ||||
|         0: "rgba(26,  188, 156, .9)", | ||||
|         ".25": "rgba(52,  152, 219, .9)", | ||||
|         ".50": "rgba(241, 196, 15,  .9)", | ||||
|         ".75": "rgba(230, 126, 34,  .9)", | ||||
|         "1.0": "rgba(211, 84,  0,   .9)", | ||||
|       }, | ||||
|       shadowBlur: 10, | ||||
|       shadowColor: "rgba(0,   0,   0,   .6)", | ||||
|       className: null, | ||||
|     }, | ||||
|     repaint = function () { | ||||
|       canvas.width = window.innerWidth; | ||||
|       canvas.height = options.barThickness * 5; // need space for shadow | ||||
|  | ||||
|       var ctx = canvas.getContext("2d"); | ||||
|       ctx.shadowBlur = options.shadowBlur; | ||||
|       ctx.shadowColor = options.shadowColor; | ||||
|  | ||||
|       var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); | ||||
|       for (var stop in options.barColors) | ||||
|         lineGradient.addColorStop(stop, options.barColors[stop]); | ||||
|       ctx.lineWidth = options.barThickness; | ||||
|       ctx.beginPath(); | ||||
|       ctx.moveTo(0, options.barThickness / 2); | ||||
|       ctx.lineTo( | ||||
|         Math.ceil(currentProgress * canvas.width), | ||||
|         options.barThickness / 2 | ||||
|       ); | ||||
|       ctx.strokeStyle = lineGradient; | ||||
|       ctx.stroke(); | ||||
|     }, | ||||
|     createCanvas = function () { | ||||
|       canvas = document.createElement("canvas"); | ||||
|       var style = canvas.style; | ||||
|       style.position = "fixed"; | ||||
|       style.top = style.left = style.right = style.margin = style.padding = 0; | ||||
|       style.zIndex = 100001; | ||||
|       style.display = "none"; | ||||
|       if (options.className) canvas.classList.add(options.className); | ||||
|       document.body.appendChild(canvas); | ||||
|       addEvent(window, "resize", repaint); | ||||
|     }, | ||||
|     topbar = { | ||||
|       config: function (opts) { | ||||
|         for (var key in opts) | ||||
|           if (options.hasOwnProperty(key)) options[key] = opts[key]; | ||||
|       }, | ||||
|       show: function () { | ||||
|         if (showing) return; | ||||
|         showing = true; | ||||
|         if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); | ||||
|         if (!canvas) createCanvas(); | ||||
|         canvas.style.opacity = 1; | ||||
|         canvas.style.display = "block"; | ||||
|         topbar.progress(0); | ||||
|         if (options.autoRun) { | ||||
|           (function loop() { | ||||
|             progressTimerId = window.requestAnimationFrame(loop); | ||||
|             topbar.progress( | ||||
|               "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) | ||||
|             ); | ||||
|           })(); | ||||
|         } | ||||
|       }, | ||||
|       progress: function (to) { | ||||
|         if (typeof to === "undefined") return currentProgress; | ||||
|         if (typeof to === "string") { | ||||
|           to = | ||||
|             (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 | ||||
|               ? currentProgress | ||||
|               : 0) + parseFloat(to); | ||||
|         } | ||||
|         currentProgress = to > 1 ? 1 : to; | ||||
|         repaint(); | ||||
|         return currentProgress; | ||||
|       }, | ||||
|       hide: function () { | ||||
|         if (!showing) return; | ||||
|         showing = false; | ||||
|         if (progressTimerId != null) { | ||||
|           window.cancelAnimationFrame(progressTimerId); | ||||
|           progressTimerId = null; | ||||
|         } | ||||
|         (function loop() { | ||||
|           if (topbar.progress("+.1") >= 1) { | ||||
|             canvas.style.opacity -= 0.05; | ||||
|             if (canvas.style.opacity <= 0.05) { | ||||
|               canvas.style.display = "none"; | ||||
|               fadeTimerId = null; | ||||
|               return; | ||||
|             } | ||||
|           } | ||||
|           fadeTimerId = window.requestAnimationFrame(loop); | ||||
|         })(); | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|   if (typeof module === "object" && typeof module.exports === "object") { | ||||
|     module.exports = topbar; | ||||
|   } else if (typeof define === "function" && define.amd) { | ||||
|     define(function () { | ||||
|       return topbar; | ||||
|     }); | ||||
|   } else { | ||||
|     this.topbar = topbar; | ||||
|   } | ||||
| }.call(this, window, document)); | ||||
| @@ -11,6 +11,8 @@ config :cannery, | ||||
|   ecto_repos: [Cannery.Repo], | ||||
|   generators: [binary_id: true] | ||||
|  | ||||
| config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||
|  | ||||
| # Configures the endpoint | ||||
| config :cannery, CanneryWeb.Endpoint, | ||||
|   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], | ||||
| @@ -18,8 +20,7 @@ config :cannery, CanneryWeb.Endpoint, | ||||
|   secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", | ||||
|   render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false], | ||||
|   pubsub_server: Cannery.PubSub, | ||||
|   live_view: [signing_salt: "zOLgd3lr"], | ||||
|   registration: System.get_env("REGISTRATION") || "invite" | ||||
|   live_view: [signing_salt: "zOLgd3lr"] | ||||
|  | ||||
| config :cannery, Cannery.Application, automigrate: false | ||||
|  | ||||
|   | ||||
| @@ -64,8 +64,9 @@ config :cannery, CanneryWeb.Endpoint, | ||||
|     ] | ||||
|   ] | ||||
|  | ||||
| # Do not include metadata nor timestamps in development logs | ||||
| config :logger, :console, format: "[$level] $message\n" | ||||
| config :logger, :console, | ||||
|   format: "[$level] $message $metadata\n\n", | ||||
|   metadata: [:data] | ||||
|  | ||||
| # Set a higher stacktrace during development. Avoid configuring such | ||||
| # in production as building large stacktraces may be expensive. | ||||
|   | ||||
| @@ -15,17 +15,18 @@ end | ||||
| config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | ||||
|  | ||||
| # Set default locale | ||||
| config :gettext, :default_locale, System.get_env("LOCALE") || "en_US" | ||||
| config :gettext, :default_locale, System.get_env("LOCALE", "en_US") | ||||
|  | ||||
| maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: [] | ||||
|  | ||||
| database_url = | ||||
|   if config_env() == :test do | ||||
|     System.get_env("TEST_DATABASE_URL") || | ||||
|     System.get_env( | ||||
|       "TEST_DATABASE_URL", | ||||
|       "ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}" | ||||
|     ) | ||||
|   else | ||||
|     System.get_env("DATABASE_URL") || | ||||
|       "ecto://postgres:postgres@cannery-db/cannery" | ||||
|     System.get_env("DATABASE_URL", "ecto://postgres:postgres@cannery-db/cannery") | ||||
|   end | ||||
|  | ||||
| host = | ||||
| @@ -40,7 +41,7 @@ interface = | ||||
| config :cannery, Cannery.Repo, | ||||
|   # ssl: true, | ||||
|   url: database_url, | ||||
|   pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), | ||||
|   pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")), | ||||
|   socket_options: maybe_ipv6 | ||||
|  | ||||
| config :cannery, CanneryWeb.Endpoint, | ||||
| @@ -49,10 +50,13 @@ config :cannery, CanneryWeb.Endpoint, | ||||
|     # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html | ||||
|     # for details about using IPv6 vs IPv4 and loopback vs public addresses. | ||||
|     ip: interface, | ||||
|     port: String.to_integer(System.get_env("PORT") || "4000") | ||||
|     port: String.to_integer(System.get_env("PORT", "4000")) | ||||
|   ], | ||||
|   server: true, | ||||
|   registration: System.get_env("REGISTRATION") || "invite" | ||||
|   server: true | ||||
|  | ||||
| if config_env() in [:dev, :prod] do | ||||
|   config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||
| end | ||||
|  | ||||
| if config_env() == :prod do | ||||
|   # The secret key base is used to sign/encrypt cookies and other secrets. | ||||
| @@ -76,12 +80,12 @@ if config_env() == :prod do | ||||
|   config :cannery, Cannery.Mailer, | ||||
|     adapter: Swoosh.Adapters.SMTP, | ||||
|     relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), | ||||
|     port: System.get_env("SMTP_PORT") || 587, | ||||
|     port: System.get_env("SMTP_PORT", "587"), | ||||
|     username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), | ||||
|     password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), | ||||
|     ssl: System.get_env("SMTP_SSL") == "true", | ||||
|     email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}", | ||||
|     email_name: System.get_env("EMAIL_NAME") || "Cannery" | ||||
|     email_from: System.get_env("EMAIL_FROM", "no-reply@#{System.get_env("HOST")}"), | ||||
|     email_name: System.get_env("EMAIL_NAME", "Cannery") | ||||
|  | ||||
|   # ## Using releases | ||||
|   # | ||||
|   | ||||
| @@ -22,6 +22,9 @@ config :cannery, CanneryWeb.Endpoint, | ||||
| # In test we don't send emails. | ||||
| config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test | ||||
|  | ||||
| # Don't require invites for signups | ||||
| config :cannery, Cannery.Accounts, registration: "public" | ||||
|  | ||||
| # Print only warnings and errors during test | ||||
| config :logger, level: :warn | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Mailer, Repo} | ||||
|   alias Cannery.Accounts.{User, UserToken} | ||||
|   alias Cannery.Accounts.{Invite, Invites, User, UserToken} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   alias Oban.Job | ||||
|  | ||||
| @@ -16,29 +16,33 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_email("foo@example.com") | ||||
|       %User{} | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- get_user_by_email("foo@example.com"), do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> get_user_by_email("unknown@example.com") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_email(String.t()) :: User.t() | nil | ||||
|   def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) | ||||
|   @spec get_user_by_email(email :: String.t()) :: User.t() | nil | ||||
|   def get_user_by_email(email) when is_binary(email) do | ||||
|     Repo.get_by(User, email: email) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a user by email and password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "correct_password") | ||||
|       %User{} | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- get_user_by_email_and_password("foo@example.com", "valid_password"), do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_email_and_password(String.t(), String.t()) :: | ||||
|   @spec get_user_by_email_and_password(email :: String.t(), password :: String.t()) :: | ||||
|           User.t() | nil | ||||
|   def get_user_by_email_and_password(email, password) | ||||
|       when is_binary(email) and is_binary(password) do | ||||
| @@ -53,28 +57,33 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user!(123) | ||||
|       %User{} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> get_user!(user.id) | ||||
|       user | ||||
|  | ||||
|       iex> get_user!(456) | ||||
|       > get_user!() | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_user!(User.t()) :: User.t() | ||||
|   def get_user!(id), do: Repo.get!(User, id) | ||||
|   def get_user!(id) do | ||||
|     Repo.get!(User, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns all users grouped by role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [admin: [%User{}], user: [%User{}, %User{}]] | ||||
|       iex> {:ok, user1} = register_user(%{email: "foo1@example.com", password: "valid_password"}) | ||||
|       iex> {:ok, user2} = register_user(%{email: "foo2@example.com", password: "valid_password"}) | ||||
|       iex> with %{admin: [^user1], user: [^user2]} <- list_all_users_by_role(user1), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} | ||||
|   @spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]} | ||||
|   def list_all_users_by_role(%User{role: :admin}) do | ||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) | ||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -82,13 +91,13 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [%User{}] | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with [^user] <- list_users_by_role(:admin), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec list_users_by_role(:admin | :user) :: [User.t()] | ||||
|   def list_users_by_role(role) do | ||||
|     role = role |> to_string() | ||||
|   @spec list_users_by_role(:admin) :: [User.t()] | ||||
|   def list_users_by_role(:admin = role) do | ||||
|     Repo.all(from u in User, where: u.role == ^role, order_by: u.email) | ||||
|   end | ||||
|  | ||||
| @@ -99,22 +108,40 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> register_user(%{field: value}) | ||||
|       {:ok, %User{}} | ||||
|       iex> with {:ok, %User{email: "foo@example.com"}} <- | ||||
|       ...>        register_user(%{email: "foo@example.com", password: "valid_password"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> register_user(%{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|       iex> with {:error, %Changeset{}} <- register_user(%{email: "foo@example"}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())} | ||||
|   def register_user(attrs) do | ||||
|     # if no registered users, make first user an admin | ||||
|     role = | ||||
|       if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0, | ||||
|         do: "admin", | ||||
|         else: "user" | ||||
|  | ||||
|     %User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert() | ||||
|   @spec register_user(attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||
|   @spec register_user(attrs :: map(), Invite.token() | nil) :: | ||||
|           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||
|   def register_user(attrs, invite_token \\ nil) do | ||||
|     Multi.new() | ||||
|     |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true)) | ||||
|     |> Multi.run(:use_invite, fn _changes_so_far, _repo -> | ||||
|       if allow_registration?() and invite_token |> is_nil() do | ||||
|         {:ok, nil} | ||||
|       else | ||||
|         Invites.use_invite(invite_token) | ||||
|       end | ||||
|     end) | ||||
|     |> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} -> | ||||
|       # if no registered users, make first user an admin | ||||
|       role = if count == 0, do: :admin, else: :user | ||||
|       User.registration_changeset(attrs, invite) |> User.role_changeset(role) | ||||
|     end) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{add_user: user}} -> {:ok, user} | ||||
|       {:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||
|       {:error, :add_user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -122,16 +149,18 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_registration(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> with %Changeset{} <- change_user_registration(), do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_registration(%{password: "hi"}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_registration(User.t() | User.new_user()) :: | ||||
|           Changeset.t(User.t() | User.new_user()) | ||||
|   @spec change_user_registration(User.t() | User.new_user(), map()) :: | ||||
|           Changeset.t(User.t() | User.new_user()) | ||||
|   def change_user_registration(user, attrs \\ %{}), | ||||
|     do: User.registration_changeset(user, attrs, hash_password: false) | ||||
|   @spec change_user_registration() :: User.changeset() | ||||
|   @spec change_user_registration(attrs :: map()) :: User.changeset() | ||||
|   def change_user_registration(attrs \\ %{}) do | ||||
|     User.registration_changeset(attrs, nil, hash_password: false) | ||||
|   end | ||||
|  | ||||
|   ## Settings | ||||
|  | ||||
| @@ -140,24 +169,29 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_email(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> with %Changeset{} <- change_user_email(%User{email: "foo@example.com"}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_email(User.t(), map()) :: Changeset.t(User.t()) | ||||
|   def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) | ||||
|   @spec change_user_email(User.t()) :: User.changeset() | ||||
|   @spec change_user_email(User.t(), attrs :: map()) :: User.changeset() | ||||
|   def change_user_email(user, attrs \\ %{}) do | ||||
|     User.email_changeset(user, attrs) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_role(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> with %Changeset{} <- change_user_role(%User{}, :user), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_role(User.t(), atom()) :: Changeset.t(User.t()) | ||||
|   def change_user_role(user, role), do: User.role_changeset(user, role) | ||||
|   @spec change_user_role(User.t(), User.role()) :: User.changeset() | ||||
|   def change_user_role(user, role) do | ||||
|     User.role_changeset(user, role) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Emulates that the email will change without actually changing | ||||
| @@ -165,15 +199,21 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> apply_user_email(user, "valid password", %{email: ...}) | ||||
|       {:ok, %User{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>        apply_user_email(user, "valid_password", %{email: "new_email@account.com"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> apply_user_email(user, "invalid password", %{email: ...}) | ||||
|       {:error, %Changeset{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        apply_user_email(user, "invalid password", %{email: "new_email@account"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec apply_user_email(User.t(), String.t(), map()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   @spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def apply_user_email(user, password, attrs) do | ||||
|     user | ||||
|     |> User.email_changeset(attrs) | ||||
| @@ -187,7 +227,7 @@ defmodule Cannery.Accounts do | ||||
|   If the token matches, the user email is updated and the token is deleted. | ||||
|   The confirmed_at date is also updated to the current time. | ||||
|   """ | ||||
|   @spec update_user_email(User.t(), String.t()) :: :ok | :error | ||||
|   @spec update_user_email(User.t(), token :: String.t()) :: :ok | :error | ||||
|   def update_user_email(user, token) do | ||||
|     context = "change:#{user.email}" | ||||
|  | ||||
| @@ -200,7 +240,7 @@ defmodule Cannery.Accounts do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t() | ||||
|   @spec user_email_multi(User.t(), email :: String.t(), context :: String.t()) :: Multi.t() | ||||
|   defp user_email_multi(user, email, context) do | ||||
|     changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() | ||||
|  | ||||
| @@ -214,11 +254,16 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{ | ||||
|       ...>        args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...>      } <- deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t() | ||||
|   @spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: | ||||
|           Job.t() | ||||
|   def deliver_update_email_instructions(user, current_email, update_email_url_fun) | ||||
|       when is_function(update_email_url_fun, 1) do | ||||
|     {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") | ||||
| @@ -231,28 +276,38 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_password(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> with %Changeset{} <- change_user_password(%User{}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_password(User.t(), map()) :: Changeset.t(User.t()) | ||||
|   def change_user_password(user, attrs \\ %{}), | ||||
|     do: User.password_changeset(user, attrs, hash_password: false) | ||||
|   @spec change_user_password(User.t(), attrs :: map()) :: User.changeset() | ||||
|   def change_user_password(user, attrs \\ %{}) do | ||||
|     User.password_changeset(user, attrs, hash_password: false) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_password(user, "valid password", %{password: ...}) | ||||
|       {:ok, %User{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>         reset_user_password(user, %{ | ||||
|       ...>           password: "new password", | ||||
|       ...>           password_confirmation: "new password" | ||||
|       ...>         }), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> update_user_password(user, "invalid password", %{password: ...}) | ||||
|       {:error, %Changeset{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        update_user_password(user, "invalid password", %{password: "123"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_password(User.t(), String.t(), map()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   @spec update_user_password(User.t(), String.t(), attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def update_user_password(user, password, attrs) do | ||||
|     changeset = | ||||
|       user | ||||
| @@ -270,49 +325,57 @@ defmodule Cannery.Accounts do | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user locale. | ||||
|   Returns an `Ecto.Changeset.t()` for changing the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_locale(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> with %Changeset{} <- change_user_locale(%User{}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_locale(User.t()) :: Changeset.t(User.t()) | ||||
|   def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) | ||||
|   @spec change_user_locale(User.t()) :: User.changeset() | ||||
|   def change_user_locale(%{locale: locale} = user) do | ||||
|     User.locale_changeset(user, locale) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_locale(user, "valid locale") | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> update_user_password(user, "invalid locale") | ||||
|       {:error, %Changeset{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- update_user_locale(user, "en_US"), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   def update_user_locale(user, locale), | ||||
|     do: user |> User.locale_changeset(locale) |> Repo.update() | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def update_user_locale(user, locale) do | ||||
|     user |> User.locale_changeset(locale) |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a user. must be performed by an admin or the same user! | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) | ||||
|       %User{} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- delete_user!(user, %User{id: 123, role: :admin}), do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> delete_user!(%User{id: 123}, %User{id: 123}) | ||||
|       %User{} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- delete_user!(user, user), do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec delete_user!(User.t(), User.t()) :: User.t() | ||||
|   def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!() | ||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!() | ||||
|   @spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() | ||||
|   def delete_user!(user, %User{role: :admin}) do | ||||
|     user |> Repo.delete!() | ||||
|   end | ||||
|  | ||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do | ||||
|     user |> Repo.delete!() | ||||
|   end | ||||
|  | ||||
|   ## Session | ||||
|  | ||||
| @@ -329,7 +392,7 @@ defmodule Cannery.Accounts do | ||||
|   @doc """ | ||||
|   Gets the user with the given signed token. | ||||
|   """ | ||||
|   @spec get_user_by_session_token(String.t()) :: User.t() | ||||
|   @spec get_user_by_session_token(token :: String.t()) :: User.t() | ||||
|   def get_user_by_session_token(token) do | ||||
|     {:ok, query} = UserToken.verify_session_token_query(token) | ||||
|     Repo.one(query) | ||||
| @@ -338,9 +401,9 @@ defmodule Cannery.Accounts do | ||||
|   @doc """ | ||||
|   Deletes the signed token with the given context. | ||||
|   """ | ||||
|   @spec delete_session_token(String.t()) :: :ok | ||||
|   @spec delete_session_token(token :: String.t()) :: :ok | ||||
|   def delete_session_token(token) do | ||||
|     Repo.delete_all(UserToken.token_and_context_query(token, "session")) | ||||
|     UserToken.token_and_context_query(token, "session") |> Repo.delete_all() | ||||
|     :ok | ||||
|   end | ||||
|  | ||||
| @@ -349,19 +412,45 @@ defmodule Cannery.Accounts do | ||||
|   """ | ||||
|   @spec allow_registration?() :: boolean() | ||||
|   def allow_registration? do | ||||
|     Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or | ||||
|     Application.get_env(:cannery, Cannery.Accounts)[:registration] == "public" or | ||||
|       list_users_by_role(:admin) |> Enum.empty?() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if user is an admin | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> is_admin?(user) | ||||
|       true | ||||
|  | ||||
|       iex> is_admin?(%User{id: Ecto.UUID.generate()}) | ||||
|       false | ||||
|  | ||||
|   """ | ||||
|   @spec is_admin?(User.t()) :: boolean() | ||||
|   def is_admin?(%User{id: user_id}) do | ||||
|     Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin) | ||||
|     |> is_nil() | ||||
|     Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks to see if user has the admin role | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> is_already_admin?(user) | ||||
|       true | ||||
|  | ||||
|       iex> is_already_admin?(%User{}) | ||||
|       false | ||||
|  | ||||
|   """ | ||||
|   @spec is_already_admin?(User.t() | nil) :: boolean() | ||||
|   def is_already_admin?(%User{role: :admin}), do: true | ||||
|   def is_already_admin?(_invalid_user), do: false | ||||
|  | ||||
|   ## Confirmation | ||||
|  | ||||
|   @doc """ | ||||
| @@ -369,10 +458,16 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{ | ||||
|       ...>   args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...> } <- deliver_user_confirmation_instructions(user, fn _token -> "example url" end), | ||||
|       ...> do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> user = user |> User.confirm_changeset() |> Repo.update!() | ||||
|       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||
|       {:error, :already_confirmed} | ||||
|  | ||||
|   """ | ||||
| @@ -394,7 +489,7 @@ defmodule Cannery.Accounts do | ||||
|   If the token matches, the user account is marked as confirmed | ||||
|   and the token is deleted. | ||||
|   """ | ||||
|   @spec confirm_user(String.t()) :: {:ok, User.t()} | atom() | ||||
|   @spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error | ||||
|   def confirm_user(token) do | ||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), | ||||
|          %User{} = user <- Repo.one(query), | ||||
| @@ -419,8 +514,12 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{args: %{ | ||||
|       ...>        email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...>    } <- deliver_user_reset_password_instructions(user, fn _token -> "example url" end), | ||||
|       ...>    do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() | ||||
| @@ -436,14 +535,17 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("validtoken") | ||||
|       %User{} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") | ||||
|       iex> Repo.insert!(user_token) | ||||
|       iex> with %User{} <- get_user_by_reset_password_token(encoded_token), do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("invalidtoken") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil | ||||
|   @spec get_user_by_reset_password_token(token :: String.t()) :: User.t() | nil | ||||
|   def get_user_by_reset_password_token(token) do | ||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), | ||||
|          %User{} = user <- Repo.one(query) do | ||||
| @@ -458,14 +560,24 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) | ||||
|       {:ok, %User{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>         reset_user_password(user, %{ | ||||
|       ...>           password: "new password", | ||||
|       ...>           password_confirmation: "new password" | ||||
|       ...>         }), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||
|       {:error, %Changeset{}} | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|  | ||||
|   """ | ||||
|   @spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   @spec reset_user_password(User.t(), attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def reset_user_password(user, attrs) do | ||||
|     Multi.new() | ||||
|     |> Multi.update(:user, User.password_changeset(user, attrs)) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| defmodule Cannery.Invites.Invite do | ||||
| defmodule Cannery.Accounts.Invite do | ||||
|   @moduledoc """ | ||||
|   An invite, created by an admin to allow someone to join their instance. An | ||||
|   invite can be enabled or disabled, and can have an optional number of uses if | ||||
| @@ -7,8 +7,8 @@ defmodule Cannery.Invites.Invite do | ||||
| 
 | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Invites.Invite} | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Ecto.{Association, Changeset, UUID} | ||||
| 
 | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
| @@ -18,38 +18,42 @@ defmodule Cannery.Invites.Invite do | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :naive_datetime | ||||
| 
 | ||||
|     belongs_to :user, User | ||||
|     belongs_to :created_by, User | ||||
| 
 | ||||
|     has_many :users, User | ||||
| 
 | ||||
|     timestamps() | ||||
|   end | ||||
| 
 | ||||
|   @type t :: %Invite{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: String.t(), | ||||
|           token: token(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: NaiveDateTime.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           created_by: User.t() | nil | Association.NotLoaded.t(), | ||||
|           created_by_id: User.id() | nil, | ||||
|           users: [User.t()] | Association.NotLoaded.t(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_invite :: %Invite{} | ||||
|   @type new_invite :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_invite()) | ||||
|   @type token :: String.t() | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec create_changeset(new_invite(), User.t(), token :: binary(), attrs :: map()) :: | ||||
|           Changeset.t(new_invite()) | ||||
|   def create_changeset(invite, %User{id: user_id}, token, attrs) do | ||||
|     invite | ||||
|     |> change(token: token, user_id: user_id) | ||||
|   @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(%User{id: user_id}, token, attrs) do | ||||
|     %__MODULE__{} | ||||
|     |> change(token: token, created_by_id: user_id) | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite()) | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
							
								
								
									
										198
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,198 @@ | ||||
| defmodule Cannery.Accounts.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Ecto.Multi | ||||
|   alias Cannery.Accounts.{Invite, User} | ||||
|   alias Cannery.Repo | ||||
|  | ||||
|   @invite_token_length 20 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of invites. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_invites(%User{id: 123, role: :admin}) | ||||
|       [%Invite{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_invites(User.t()) :: [Invite.t()] | ||||
|   def list_invites(%User{role: :admin}) do | ||||
|     Repo.all(from i in Invite, order_by: i.name) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single invite for a user | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|       > get_invite!(456, %User{id: 123, role: :admin}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||
|   def get_invite!(id, %User{role: :admin}) do | ||||
|     Repo.get!(Invite, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns if an invite token is still valid | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> valid_invite_token?("valid_token") | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> valid_invite_token?("invalid_token") | ||||
|       nil | ||||
|   """ | ||||
|   @spec valid_invite_token?(Invite.token() | nil) :: boolean() | ||||
|   def valid_invite_token?(token) when token in [nil, ""], do: false | ||||
|  | ||||
|   def valid_invite_token?(token) do | ||||
|     Repo.exists?( | ||||
|       from i in Invite, | ||||
|         where: i.token == ^token, | ||||
|         where: i.disabled_at |> is_nil() | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||
|   completely used. | ||||
|   """ | ||||
|   @spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||
|   def use_invite(invite_token) do | ||||
|     Multi.new() | ||||
|     |> Multi.run(:invite, fn _changes_so_far, _repo -> | ||||
|       invite_token |> get_invite_by_token() | ||||
|     end) | ||||
|     |> Multi.update(:decrement_invite, fn %{invite: invite} -> | ||||
|       decrement_invite_changeset(invite) | ||||
|     end) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{decrement_invite: invite}} -> {:ok, invite} | ||||
|       {:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||
|   defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token} | ||||
|  | ||||
|   defp get_invite_by_token(token) do | ||||
|     Repo.one( | ||||
|       from i in Invite, | ||||
|         where: i.token == ^token, | ||||
|         where: i.disabled_at |> is_nil() | ||||
|     ) | ||||
|     |> case do | ||||
|       nil -> {:error, :invalid_token} | ||||
|       invite -> {:ok, invite} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | ||||
|   def get_use_count(%Invite{id: invite_id}, %User{role: :admin}) do | ||||
|     Repo.one( | ||||
|       from u in User, | ||||
|         where: u.invite_id == ^invite_id, | ||||
|         select: count(u.id) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @spec decrement_invite_changeset(Invite.t()) :: Invite.changeset() | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do | ||||
|     invite |> Invite.update_changeset(%{}) | ||||
|   end | ||||
|  | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do | ||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|     invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) | ||||
|   end | ||||
|  | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do | ||||
|     invite |> Invite.update_changeset(%{uses_left: uses_left - 1}) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_invite(User.t(), attrs :: map()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def create_invite(%User{role: :admin} = user, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     Invite.create_changeset(user, token, attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def update_invite(invite, attrs, %User{role: :admin}) do | ||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite(Invite.t(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def delete_invite(invite, %User{role: :admin}) do | ||||
|     invite |> Repo.delete() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||
|   def delete_invite!(invite, %User{role: :admin}) do | ||||
|     invite |> Repo.delete!() | ||||
|   end | ||||
| end | ||||
| @@ -1,14 +1,24 @@ | ||||
| defmodule Cannery.Accounts.User do | ||||
|   @moduledoc """ | ||||
|   A cannery user | ||||
|   A Cannery user | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Invites.Invite} | ||||
|   alias Ecto.{Association, Changeset, UUID} | ||||
|   alias Cannery.Accounts.{Invite, User} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :email, | ||||
|              :confirmed_at, | ||||
|              :role, | ||||
|              :locale, | ||||
|              :inserted_at, | ||||
|              :updated_at | ||||
|            ]} | ||||
|   @derive {Inspect, except: [:password]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
| @@ -20,7 +30,9 @@ defmodule Cannery.Accounts.User do | ||||
|     field :role, Ecto.Enum, values: [:admin, :user], default: :user | ||||
|     field :locale, :string | ||||
|  | ||||
|     has_many :invites, Invite, on_delete: :delete_all | ||||
|     has_many :created_invites, Invite, foreign_key: :created_by_id | ||||
|  | ||||
|     belongs_to :invite, Invite | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -31,14 +43,18 @@ defmodule Cannery.Accounts.User do | ||||
|           password: String.t(), | ||||
|           hashed_password: String.t(), | ||||
|           confirmed_at: NaiveDateTime.t(), | ||||
|           role: atom(), | ||||
|           role: role(), | ||||
|           locale: String.t() | nil, | ||||
|           invites: [Invite.t()], | ||||
|           created_invites: [Invite.t()] | Association.NotLoaded.t(), | ||||
|           invite: Invite.t() | nil | Association.NotLoaded.t(), | ||||
|           invite_id: Invite.id() | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_user :: %User{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_user()) | ||||
|   @type role :: :admin | :user | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for registration. | ||||
| @@ -57,26 +73,25 @@ defmodule Cannery.Accounts.User do | ||||
|       validations on a LiveView form), this option can be set to `false`. | ||||
|       Defaults to `true`. | ||||
|   """ | ||||
|   @spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user()) | ||||
|   @spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   def registration_changeset(user, attrs, opts \\ []) do | ||||
|     user | ||||
|     |> cast(attrs, [:email, :password, :role, :locale]) | ||||
|   @spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset() | ||||
|   @spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset() | ||||
|   def registration_changeset(attrs, invite, opts \\ []) do | ||||
|     %User{} | ||||
|     |> cast(attrs, [:email, :password, :locale]) | ||||
|     |> put_change(:invite_id, if(invite, do: invite.id)) | ||||
|     |> validate_email() | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for role. | ||||
|  | ||||
|   """ | ||||
|   @spec role_changeset(t(), role :: atom()) :: Changeset.t(t()) | ||||
|   @spec role_changeset(t() | new_user() | changeset(), role()) :: changeset() | ||||
|   def role_changeset(user, role) do | ||||
|     user |> cast(%{"role" => role}, [:role]) | ||||
|     user |> change(role: role) | ||||
|   end | ||||
|  | ||||
|   @spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user()) | ||||
|   @spec validate_email(changeset()) :: changeset() | ||||
|   defp validate_email(changeset) do | ||||
|     changeset | ||||
|     |> validate_required([:email]) | ||||
| @@ -88,8 +103,8 @@ defmodule Cannery.Accounts.User do | ||||
|     |> unique_constraint(:email) | ||||
|   end | ||||
|  | ||||
|   @spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   @spec validate_password(changeset(), opts :: keyword()) :: | ||||
|           changeset() | ||||
|   defp validate_password(changeset, opts) do | ||||
|     changeset | ||||
|     |> validate_required([:password]) | ||||
| @@ -100,8 +115,7 @@ defmodule Cannery.Accounts.User do | ||||
|     |> maybe_hash_password(opts) | ||||
|   end | ||||
|  | ||||
|   @spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   @spec maybe_hash_password(changeset(), opts :: keyword()) :: changeset() | ||||
|   defp maybe_hash_password(changeset, opts) do | ||||
|     hash_password? = Keyword.get(opts, :hash_password, true) | ||||
|     password = get_change(changeset, :password) | ||||
| @@ -120,7 +134,7 @@ defmodule Cannery.Accounts.User do | ||||
|  | ||||
|   It requires the email to change otherwise an error is added. | ||||
|   """ | ||||
|   @spec email_changeset(t(), attrs :: map()) :: Changeset.t(t()) | ||||
|   @spec email_changeset(t(), attrs :: map()) :: changeset() | ||||
|   def email_changeset(user, attrs) do | ||||
|     user | ||||
|     |> cast(attrs, [:email]) | ||||
| @@ -143,8 +157,8 @@ defmodule Cannery.Accounts.User do | ||||
|       validations on a LiveView form), this option can be set to `false`. | ||||
|       Defaults to `true`. | ||||
|   """ | ||||
|   @spec password_changeset(t(), attrs :: map()) :: Changeset.t(t()) | ||||
|   @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t()) | ||||
|   @spec password_changeset(t(), attrs :: map()) :: changeset() | ||||
|   @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: changeset() | ||||
|   def password_changeset(user, attrs, opts \\ []) do | ||||
|     user | ||||
|     |> cast(attrs, [:password]) | ||||
| @@ -155,7 +169,7 @@ defmodule Cannery.Accounts.User do | ||||
|   @doc """ | ||||
|   Confirms the account by setting `confirmed_at`. | ||||
|   """ | ||||
|   @spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t()) | ||||
|   @spec confirm_changeset(t() | changeset()) :: changeset() | ||||
|   def confirm_changeset(user_or_changeset) do | ||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|     user_or_changeset |> change(confirmed_at: now) | ||||
| @@ -181,7 +195,7 @@ defmodule Cannery.Accounts.User do | ||||
|   @doc """ | ||||
|   Validates the current password otherwise adds an error to the changeset. | ||||
|   """ | ||||
|   @spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t()) | ||||
|   @spec validate_current_password(changeset(), String.t()) :: changeset() | ||||
|   def validate_current_password(changeset, password) do | ||||
|     if valid_password?(changeset.data, password), | ||||
|       do: changeset, | ||||
| @@ -191,7 +205,7 @@ defmodule Cannery.Accounts.User do | ||||
|   @doc """ | ||||
|   A changeset for changing the user's locale | ||||
|   """ | ||||
|   @spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t()) | ||||
|   @spec locale_changeset(t() | changeset(), locale :: String.t() | nil) :: changeset() | ||||
|   def locale_changeset(user_or_changeset, locale) do | ||||
|     user_or_changeset | ||||
|     |> cast(%{"locale" => locale}, [:locale]) | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| defmodule Cannery.Accounts.UserToken do | ||||
|   @moduledoc """ | ||||
|   Schema for serialized user session and authentication tokens | ||||
|   Schema for a user's session token | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Query | ||||
|   alias Ecto.{Query, UUID} | ||||
|   alias Cannery.{Accounts.User, Accounts.UserToken} | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Ecto.{Association, UUID} | ||||
|  | ||||
|   @hash_algorithm :sha256 | ||||
|   @rand_size 32 | ||||
| @@ -30,27 +30,27 @@ defmodule Cannery.Accounts.UserToken do | ||||
|     timestamps(updated_at: false) | ||||
|   end | ||||
|  | ||||
|   @type t :: %UserToken{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           token: String.t(), | ||||
|           token: token(), | ||||
|           context: String.t(), | ||||
|           sent_to: String.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           user: User.t() | Association.NotLoaded.t(), | ||||
|           user_id: User.id() | nil, | ||||
|           inserted_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_token :: %UserToken{} | ||||
|   @type new_user_token :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type token :: binary() | ||||
|  | ||||
|   @doc """ | ||||
|   Generates a token that will be stored in a signed place, | ||||
|   such as session or cookie. As they are signed, those | ||||
|   tokens do not need to be hashed. | ||||
|   """ | ||||
|   @spec build_session_token(User.t()) :: {token :: String.t(), new_token()} | ||||
|   def build_session_token(%{id: user_id}) do | ||||
|   def build_session_token(user) do | ||||
|     token = :crypto.strong_rand_bytes(@rand_size) | ||||
|     {token, %UserToken{token: token, context: "session", user_id: user_id}} | ||||
|     {token, %__MODULE__{token: token, context: "session", user_id: user.id}} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -58,7 +58,6 @@ defmodule Cannery.Accounts.UserToken do | ||||
|  | ||||
|   The query returns the user found by the token. | ||||
|   """ | ||||
|   @spec verify_session_token_query(token :: String.t()) :: {:ok, Query.t()} | ||||
|   def verify_session_token_query(token) do | ||||
|     query = | ||||
|       from token in token_and_context_query(token, "session"), | ||||
| @@ -77,19 +76,16 @@ defmodule Cannery.Accounts.UserToken do | ||||
|   The token is valid for a week as long as users don't change | ||||
|   their email. | ||||
|   """ | ||||
|   @spec build_email_token(User.t(), context :: String.t()) :: {token :: String.t(), new_token()} | ||||
|   def build_email_token(user, context) do | ||||
|     build_hashed_token(user, context, user.email) | ||||
|   end | ||||
|  | ||||
|   @spec build_hashed_token(User.t(), String.t(), String.t()) :: | ||||
|           {String.t(), new_token()} | ||||
|   defp build_hashed_token(user, context, sent_to) do | ||||
|     token = :crypto.strong_rand_bytes(@rand_size) | ||||
|     hashed_token = :crypto.hash(@hash_algorithm, token) | ||||
|  | ||||
|     {Base.url_encode64(token, padding: false), | ||||
|      %UserToken{ | ||||
|      %__MODULE__{ | ||||
|        token: hashed_token, | ||||
|        context: context, | ||||
|        sent_to: sent_to, | ||||
| @@ -102,8 +98,6 @@ defmodule Cannery.Accounts.UserToken do | ||||
|  | ||||
|   The query returns the user found by the token. | ||||
|   """ | ||||
|   @spec verify_email_token_query(token :: String.t(), context :: String.t()) :: | ||||
|           {:ok, Query.t()} | :error | ||||
|   def verify_email_token_query(token, context) do | ||||
|     case Base.url_decode64(token, padding: false) do | ||||
|       {:ok, decoded_token} -> | ||||
| @@ -123,7 +117,6 @@ defmodule Cannery.Accounts.UserToken do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec days_for_context(context :: <<_::56>>) :: non_neg_integer() | ||||
|   defp days_for_context("confirm"), do: @confirm_validity_in_days | ||||
|   defp days_for_context("reset_password"), do: @reset_password_validity_in_days | ||||
|  | ||||
| @@ -132,8 +125,6 @@ defmodule Cannery.Accounts.UserToken do | ||||
|  | ||||
|   The query returns the user token record. | ||||
|   """ | ||||
|   @spec verify_change_email_token_query(token :: String.t(), context :: String.t()) :: | ||||
|           {:ok, Query.t()} | :error | ||||
|   def verify_change_email_token_query(token, context) do | ||||
|     case Base.url_decode64(token, padding: false) do | ||||
|       {:ok, decoded_token} -> | ||||
| @@ -153,21 +144,18 @@ defmodule Cannery.Accounts.UserToken do | ||||
|   @doc """ | ||||
|   Returns the given token with the given context. | ||||
|   """ | ||||
|   @spec token_and_context_query(token :: String.t(), context :: String.t()) :: Query.t() | ||||
|   def token_and_context_query(token, context) do | ||||
|     from UserToken, where: [token: ^token, context: ^context] | ||||
|     from __MODULE__, where: [token: ^token, context: ^context] | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets all tokens for the given user for the given contexts. | ||||
|   """ | ||||
|   @spec user_and_contexts_query(User.t(), contexts :: :all | nonempty_maybe_improper_list()) :: | ||||
|           Query.t() | ||||
|   def user_and_contexts_query(%{id: user_id}, :all) do | ||||
|     from t in UserToken, where: t.user_id == ^user_id | ||||
|   def user_and_contexts_query(user, :all) do | ||||
|     from t in __MODULE__, where: t.user_id == ^user.id | ||||
|   end | ||||
|  | ||||
|   def user_and_contexts_query(%{id: user_id}, [_ | _] = contexts) do | ||||
|     from t in UserToken, where: t.user_id == ^user_id and t.context in ^contexts | ||||
|   def user_and_contexts_query(user, [_ | _] = contexts) do | ||||
|     from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -5,7 +5,7 @@ defmodule Cannery.ActivityLog do | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   alias Ecto.Multi | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of shot_groups. | ||||
| @@ -15,10 +15,50 @@ defmodule Cannery.ActivityLog do | ||||
|       iex> list_shot_groups(%User{id: 123}) | ||||
|       [%ShotGroup{}, ...] | ||||
|  | ||||
|       iex> list_shot_groups("cool", %User{id: 123}) | ||||
|       [%ShotGroup{notes: "My cool shot group"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_shot_groups(User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups(%User{id: user_id}) do | ||||
|     Repo.all(from(sg in ShotGroup, where: sg.user_id == ^user_id)) | ||||
|   @spec list_shot_groups(search :: nil | String.t(), User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups(search \\ nil, user) | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from sg in ShotGroup, where: sg.user_id == ^user_id) | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         left_join: ag in assoc(sg, :ammo_group), | ||||
|         left_join: at in assoc(ag, :ammo_type), | ||||
|         where: sg.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "? @@ websearch_to_tsquery('english', ?)", | ||||
|             sg.search, | ||||
|             ^trimmed_search | ||||
|           ) or | ||||
|             fragment( | ||||
|               "? @@ websearch_to_tsquery('english', ?)", | ||||
|               ag.search, | ||||
|               ^trimmed_search | ||||
|             ) or | ||||
|             fragment( | ||||
|               "? @@ websearch_to_tsquery('english', ?)", | ||||
|               at.search, | ||||
|               ^trimmed_search | ||||
|             ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|             sg.search, | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -58,7 +98,7 @@ defmodule Cannery.ActivityLog do | ||||
|  | ||||
|   """ | ||||
|   @spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil} | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} | ||||
|   def create_shot_group(attrs, user, ammo_group) do | ||||
|     Multi.new() | ||||
|     |> Multi.insert( | ||||
| @@ -99,7 +139,7 @@ defmodule Cannery.ActivityLog do | ||||
|  | ||||
|   """ | ||||
|   @spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil} | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} | ||||
|   def update_shot_group( | ||||
|         %ShotGroup{count: count, user_id: user_id} = shot_group, | ||||
|         attrs, | ||||
| @@ -149,7 +189,7 @@ defmodule Cannery.ActivityLog do | ||||
|  | ||||
|   """ | ||||
|   @spec delete_shot_group(ShotGroup.t(), User.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t())} | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset()} | ||||
|   def delete_shot_group( | ||||
|         %ShotGroup{user_id: user_id} = shot_group, | ||||
|         %User{id: user_id} | ||||
|   | ||||
| @@ -9,6 +9,14 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :count, | ||||
|              :date, | ||||
|              :notes, | ||||
|              :ammo_group_id | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "shot_groups" do | ||||
| @@ -36,6 +44,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|         } | ||||
|   @type new_shot_group :: %ShotGroup{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_shot_group()) | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset( | ||||
| @@ -43,8 +52,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|           User.t() | any(), | ||||
|           AmmoGroup.t() | any(), | ||||
|           attrs :: map() | ||||
|         ) :: | ||||
|           Changeset.t(new_shot_group()) | ||||
|         ) :: changeset() | ||||
|   def create_changeset( | ||||
|         shot_group, | ||||
|         %User{id: user_id}, | ||||
| @@ -58,7 +66,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_create_shot_group_count(ammo_group) | ||||
|     |> validate_required([:count, :ammo_group_id, :user_id]) | ||||
|     |> validate_required([:count, :date, :ammo_group_id, :user_id]) | ||||
|   end | ||||
|  | ||||
|   def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do | ||||
| @@ -66,7 +74,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :ammo_group_id, :user_id]) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo group")) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack")) | ||||
|   end | ||||
|  | ||||
|   defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do | ||||
| @@ -79,8 +87,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_shot_group()) | ||||
|   @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset() | ||||
|   def update_changeset( | ||||
|         %ShotGroup{user_id: user_id} = shot_group, | ||||
|         %User{id: user_id} = user, | ||||
| @@ -90,7 +97,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|     shot_group | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count]) | ||||
|     |> validate_required([:count, :date]) | ||||
|     |> validate_update_shot_group_count(shot_group, user) | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, Containers, Repo} | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo} | ||||
|   alias Cannery.ActivityLog.ShotGroup | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Ecto.Changeset | ||||
| @@ -20,11 +20,38 @@ defmodule Cannery.Ammo do | ||||
|       iex> list_ammo_types(%User{id: 123}) | ||||
|       [%AmmoType{}, ...] | ||||
|  | ||||
|       iex> list_ammo_types("cool", %User{id: 123}) | ||||
|       [%AmmoType{name: "My cool ammo type"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_ammo_types(User.t()) :: [AmmoType.t()] | ||||
|   def list_ammo_types(%User{id: user_id}), | ||||
|   @spec list_ammo_types(search :: nil | String.t(), User.t()) :: [AmmoType.t()] | ||||
|   def list_ammo_types(search \\ nil, user) | ||||
|  | ||||
|   def list_ammo_types(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from at in AmmoType, where: at.user_id == ^user_id, order_by: at.name) | ||||
|  | ||||
|   def list_ammo_types(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from at in AmmoType, | ||||
|         where: at.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "search @@ websearch_to_tsquery('english', ?)", | ||||
|             ^trimmed_search | ||||
|           ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)", | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a count of ammo_types. | ||||
|  | ||||
| @@ -101,7 +128,7 @@ defmodule Cannery.Ammo do | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_round_count_for_ammo_type(123, %User{id: 123}) | ||||
|       %AmmoType{} | ||||
|       35 | ||||
|  | ||||
|       iex> get_round_count_for_ammo_type(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
| @@ -127,7 +154,7 @@ defmodule Cannery.Ammo do | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(123, %User{id: 123}) | ||||
|       %AmmoType{} | ||||
|       35 | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
| @@ -146,6 +173,29 @@ defmodule Cannery.Ammo do | ||||
|     ) || 0 | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the total number of ammo ever bought for an ammo type | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Ammo type does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_historical_count_for_ammo_type(123, %User{id: 123}) | ||||
|       %AmmoType{} | ||||
|  | ||||
|       iex> get_historical_count_for_ammo_type(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_historical_count_for_ammo_type(AmmoType.t(), User.t()) :: non_neg_integer() | ||||
|   def get_historical_count_for_ammo_type( | ||||
|         %AmmoType{user_id: user_id} = ammo_type, | ||||
|         %User{id: user_id} = user | ||||
|       ) do | ||||
|     get_round_count_for_ammo_type(ammo_type, user) + | ||||
|       get_used_count_for_ammo_type(ammo_type, user) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a ammo_type. | ||||
|  | ||||
| @@ -159,7 +209,7 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec create_ammo_type(attrs :: map(), User.t()) :: | ||||
|           {:ok, AmmoType.t()} | {:error, Changeset.t(AmmoType.new_ammo_type())} | ||||
|           {:ok, AmmoType.t()} | {:error, AmmoType.changeset()} | ||||
|   def create_ammo_type(attrs \\ %{}, %User{} = user), | ||||
|     do: %AmmoType{} |> AmmoType.create_changeset(user, attrs) |> Repo.insert() | ||||
|  | ||||
| @@ -176,7 +226,7 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec update_ammo_type(AmmoType.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, AmmoType.t()} | {:error, Changeset.t(AmmoType.t())} | ||||
|           {:ok, AmmoType.t()} | {:error, AmmoType.changeset()} | ||||
|   def update_ammo_type(%AmmoType{user_id: user_id} = ammo_type, attrs, %User{id: user_id}), | ||||
|     do: ammo_type |> AmmoType.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
| @@ -193,7 +243,7 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec delete_ammo_type(AmmoType.t(), User.t()) :: | ||||
|           {:ok, AmmoType.t()} | {:error, Changeset.t(AmmoType.t())} | ||||
|           {:ok, AmmoType.t()} | {:error, AmmoType.changeset()} | ||||
|   def delete_ammo_type(%AmmoType{user_id: user_id} = ammo_type, %User{id: user_id}), | ||||
|     do: ammo_type |> Repo.delete() | ||||
|  | ||||
| @@ -220,7 +270,15 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec list_ammo_groups_for_type(AmmoType.t(), User.t()) :: [AmmoGroup.t()] | ||||
|   def list_ammo_groups_for_type(%AmmoType{id: ammo_type_id, user_id: user_id}, %User{id: user_id}) do | ||||
|   @spec list_ammo_groups_for_type(AmmoType.t(), User.t(), include_empty :: boolean()) :: | ||||
|           [AmmoGroup.t()] | ||||
|   def list_ammo_groups_for_type(ammo_type, user, include_empty \\ false) | ||||
|  | ||||
|   def list_ammo_groups_for_type( | ||||
|         %AmmoType{id: ammo_type_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         true = _include_empty | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
| @@ -231,35 +289,221 @@ defmodule Cannery.Ammo do | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def list_ammo_groups_for_type( | ||||
|         %AmmoType{id: ammo_type_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         false = _include_empty | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
|         where: ag.ammo_type_id == ^ammo_type_id, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: not (ag.count == 0), | ||||
|         preload: [shot_groups: sg], | ||||
|         order_by: ag.id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of ammo_groups for a user. | ||||
|   Returns the list of ammo_groups for a user and container. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_ammo_groups_for_container(%AmmoType{id: 123}, %User{id: 123}) | ||||
|       [%AmmoGroup{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_ammo_groups_for_container(Container.t(), User.t()) :: [AmmoGroup.t()] | ||||
|   @spec list_ammo_groups_for_container(Container.t(), User.t(), include_empty :: boolean()) :: | ||||
|           [AmmoGroup.t()] | ||||
|   def list_ammo_groups_for_container(container, user, include_empty \\ false) | ||||
|  | ||||
|   def list_ammo_groups_for_container( | ||||
|         %Container{id: container_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         true = _include_empty | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
|         where: ag.container_id == ^container_id, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         preload: [shot_groups: sg], | ||||
|         order_by: ag.id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def list_ammo_groups_for_container( | ||||
|         %Container{id: container_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         false = _include_empty | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
|         where: ag.container_id == ^container_id, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: not (ag.count == 0), | ||||
|         preload: [shot_groups: sg], | ||||
|         order_by: ag.id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the count of ammo_groups for an ammo type. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_ammo_groups_count_for_type(%AmmoType{id: 123}, %User{id: 123}) | ||||
|       3 | ||||
|  | ||||
|       iex> get_ammo_groups_count_for_type(%AmmoType{id: 123}, %User{id: 123}, true) | ||||
|       5 | ||||
|  | ||||
|   """ | ||||
|   @spec get_ammo_groups_count_for_type(AmmoType.t(), User.t()) :: [AmmoGroup.t()] | ||||
|   @spec get_ammo_groups_count_for_type(AmmoType.t(), User.t(), include_empty :: boolean()) :: | ||||
|           [AmmoGroup.t()] | ||||
|   def get_ammo_groups_count_for_type(ammo_type, user, include_empty \\ false) | ||||
|  | ||||
|   def get_ammo_groups_count_for_type( | ||||
|         %AmmoType{id: ammo_type_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         true = _include_empty | ||||
|       ) do | ||||
|     Repo.one!( | ||||
|       from ag in AmmoGroup, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: ag.ammo_type_id == ^ammo_type_id, | ||||
|         distinct: true, | ||||
|         select: count(ag.id) | ||||
|     ) || 0 | ||||
|   end | ||||
|  | ||||
|   def get_ammo_groups_count_for_type( | ||||
|         %AmmoType{id: ammo_type_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         false = _include_empty | ||||
|       ) do | ||||
|     Repo.one!( | ||||
|       from ag in AmmoGroup, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: ag.ammo_type_id == ^ammo_type_id, | ||||
|         where: not (ag.count == 0), | ||||
|         distinct: true, | ||||
|         select: count(ag.id) | ||||
|     ) || 0 | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the count of used ammo_groups for an ammo type. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_ammo_groups_count_for_type(%AmmoType{id: 123}, %User{id: 123}) | ||||
|       3 | ||||
|  | ||||
|   """ | ||||
|   @spec get_used_ammo_groups_count_for_type(AmmoType.t(), User.t()) :: [AmmoGroup.t()] | ||||
|   def get_used_ammo_groups_count_for_type( | ||||
|         %AmmoType{id: ammo_type_id, user_id: user_id}, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     Repo.one!( | ||||
|       from ag in AmmoGroup, | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: ag.ammo_type_id == ^ammo_type_id, | ||||
|         where: ag.count == 0, | ||||
|         distinct: true, | ||||
|         select: count(ag.id) | ||||
|     ) || 0 | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of ammo_groups. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_ammo_groups(%User{id: 123}) | ||||
|       [%AmmoGroup{}, ...] | ||||
|  | ||||
|       iex> list_ammo_groups("cool", true, %User{id: 123}) | ||||
|       [%AmmoGroup{notes: "My cool ammo group"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_ammo_groups(User.t()) :: [AmmoGroup.t()] | ||||
|   @spec list_ammo_groups(User.t(), include_empty :: boolean()) :: [AmmoGroup.t()] | ||||
|   def list_ammo_groups(%User{id: user_id}, include_empty \\ false) do | ||||
|     if include_empty do | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
|         where: ag.user_id == ^user_id, | ||||
|         preload: [shot_groups: sg], | ||||
|         order_by: ag.id | ||||
|     else | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in assoc(ag, :shot_groups), | ||||
|         where: ag.user_id == ^user_id, | ||||
|         where: not (ag.count == 0), | ||||
|         preload: [shot_groups: sg], | ||||
|         order_by: ag.id | ||||
|     end | ||||
|   @spec list_ammo_groups(search :: nil | String.t(), User.t()) :: [AmmoGroup.t()] | ||||
|   @spec list_ammo_groups(search :: nil | String.t(), include_empty :: boolean(), User.t()) :: | ||||
|           [AmmoGroup.t()] | ||||
|   def list_ammo_groups(search \\ nil, include_empty \\ false, %{id: user_id}) do | ||||
|     from( | ||||
|       ag in AmmoGroup, | ||||
|       as: :ag, | ||||
|       left_join: sg in assoc(ag, :shot_groups), | ||||
|       as: :sg, | ||||
|       join: at in assoc(ag, :ammo_type), | ||||
|       as: :at, | ||||
|       join: c in assoc(ag, :container), | ||||
|       as: :c, | ||||
|       left_join: t in assoc(c, :tags), | ||||
|       as: :t, | ||||
|       where: ag.user_id == ^user_id, | ||||
|       preload: [shot_groups: sg, ammo_type: at, container: {c, tags: t}], | ||||
|       order_by: ag.id | ||||
|     ) | ||||
|     |> list_ammo_groups_include_empty(include_empty) | ||||
|     |> list_ammo_groups_search(search) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_groups_include_empty(query, true), do: query | ||||
|  | ||||
|   defp list_ammo_groups_include_empty(query, false) do | ||||
|     query |> where([ag], not (ag.count == 0)) | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_groups_search(query, nil), do: query | ||||
|   defp list_ammo_groups_search(query, ""), do: query | ||||
|  | ||||
|   defp list_ammo_groups_search(query, search) do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     query | ||||
|     |> where( | ||||
|       [ag: ag, at: at, c: c, t: t], | ||||
|       fragment( | ||||
|         "? @@ websearch_to_tsquery('english', ?)", | ||||
|         ag.search, | ||||
|         ^trimmed_search | ||||
|       ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           at.search, | ||||
|           ^trimmed_search | ||||
|         ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           c.search, | ||||
|           ^trimmed_search | ||||
|         ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           t.search, | ||||
|           ^trimmed_search | ||||
|         ) | ||||
|     ) | ||||
|     |> order_by( | ||||
|       [ag: ag], | ||||
|       desc: | ||||
|         fragment( | ||||
|           "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|           ag.search, | ||||
|           ^trimmed_search | ||||
|         ) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of staged ammo_groups for a user. | ||||
|  | ||||
| @@ -318,6 +562,17 @@ defmodule Cannery.Ammo do | ||||
|     |> Enum.sum() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the last entered shot group for an ammo group | ||||
|   """ | ||||
|   @spec get_last_used_shot_group(AmmoGroup.t()) :: ShotGroup.t() | nil | ||||
|   def get_last_used_shot_group(%AmmoGroup{} = ammo_group) do | ||||
|     ammo_group | ||||
|     |> Repo.preload(:shot_groups) | ||||
|     |> Map.fetch!(:shot_groups) | ||||
|     |> Enum.max_by(fn %{date: date} -> date end, Date, fn -> nil end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Calculates the percentage remaining of an ammo group out of 100 | ||||
|   """ | ||||
| @@ -328,11 +583,35 @@ defmodule Cannery.Ammo do | ||||
|     ammo_group = ammo_group |> Repo.preload(:shot_groups) | ||||
|  | ||||
|     shot_group_sum = | ||||
|       ammo_group.shot_groups |> Enum.map(fn %{count: count} -> count end) |> Enum.sum() | ||||
|       ammo_group.shot_groups | ||||
|       |> Enum.map(fn %{count: count} -> count end) | ||||
|       |> Enum.sum() | ||||
|  | ||||
|     round(count / (count + shot_group_sum) * 100) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the original count for an ammo group | ||||
|   """ | ||||
|   @spec get_original_count(AmmoGroup.t()) :: non_neg_integer() | ||||
|   def get_original_count(%AmmoGroup{count: count} = ammo_group) do | ||||
|     count + get_used_count(ammo_group) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Calculates the CPR for a single ammo group | ||||
|   """ | ||||
|   @spec get_cpr(AmmoGroup.t()) :: nil | float() | ||||
|   def get_cpr(%AmmoGroup{price_paid: nil}), do: nil | ||||
|  | ||||
|   def get_cpr(%AmmoGroup{price_paid: price_paid} = ammo_group), | ||||
|     do: calculate_cpr(price_paid, get_original_count(ammo_group)) | ||||
|  | ||||
|   @spec calculate_cpr(price_paid :: float() | nil, count :: integer()) :: float() | nil | ||||
|   defp calculate_cpr(nil, _count), do: nil | ||||
|   defp calculate_cpr(_price_paid, 0), do: nil | ||||
|   defp calculate_cpr(price_paid, total_count), do: price_paid / total_count | ||||
|  | ||||
|   @doc """ | ||||
|   Creates multiple ammo_groups at once. | ||||
|  | ||||
| @@ -347,7 +626,7 @@ defmodule Cannery.Ammo do | ||||
|   """ | ||||
|   @spec create_ammo_groups(attrs :: map(), multiplier :: non_neg_integer(), User.t()) :: | ||||
|           {:ok, {count :: non_neg_integer(), [AmmoGroup.t()] | nil}} | ||||
|           | {:error, Changeset.t(AmmoGroup.new_ammo_group())} | ||||
|           | {:error, AmmoGroup.changeset()} | ||||
|   def create_ammo_groups( | ||||
|         %{"ammo_type_id" => ammo_type_id, "container_id" => container_id} = attrs, | ||||
|         multiplier, | ||||
| @@ -426,9 +705,13 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec update_ammo_group(AmmoGroup.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, AmmoGroup.t()} | {:error, Changeset.t(AmmoGroup.t())} | ||||
|   def update_ammo_group(%AmmoGroup{user_id: user_id} = ammo_group, attrs, %User{id: user_id}), | ||||
|     do: ammo_group |> AmmoGroup.update_changeset(attrs) |> Repo.update() | ||||
|           {:ok, AmmoGroup.t()} | {:error, AmmoGroup.changeset()} | ||||
|   def update_ammo_group( | ||||
|         %AmmoGroup{user_id: user_id} = ammo_group, | ||||
|         attrs, | ||||
|         %User{id: user_id} = user | ||||
|       ), | ||||
|       do: ammo_group |> AmmoGroup.update_changeset(attrs, user) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a ammo_group. | ||||
| @@ -443,7 +726,7 @@ defmodule Cannery.Ammo do | ||||
|  | ||||
|   """ | ||||
|   @spec delete_ammo_group(AmmoGroup.t(), User.t()) :: | ||||
|           {:ok, AmmoGroup.t()} | {:error, Changeset.t(AmmoGroup.t())} | ||||
|           {:ok, AmmoGroup.t()} | {:error, AmmoGroup.changeset()} | ||||
|   def delete_ammo_group(%AmmoGroup{user_id: user_id} = ammo_group, %User{id: user_id}), | ||||
|     do: ammo_group |> Repo.delete() | ||||
|  | ||||
|   | ||||
| @@ -10,9 +10,19 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers.Container} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers, Containers.Container} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :count, | ||||
|              :notes, | ||||
|              :price_paid, | ||||
|              :staged, | ||||
|              :ammo_type_id, | ||||
|              :container_id | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "ammo_groups" do | ||||
| @@ -20,6 +30,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|     field :notes, :string | ||||
|     field :price_paid, :float | ||||
|     field :staged, :boolean, default: false | ||||
|     field :purchased_on, :date | ||||
|  | ||||
|     belongs_to :ammo_type, AmmoType | ||||
|     belongs_to :container, Container | ||||
| @@ -36,6 +47,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|           notes: String.t() | nil, | ||||
|           price_paid: float() | nil, | ||||
|           staged: boolean(), | ||||
|           purchased_on: Date.t(), | ||||
|           ammo_type: AmmoType.t() | nil, | ||||
|           ammo_type_id: AmmoType.id(), | ||||
|           container: Container.t() | nil, | ||||
| @@ -47,6 +59,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|         } | ||||
|   @type new_ammo_group :: %AmmoGroup{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_group()) | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset( | ||||
| @@ -55,7 +68,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|           Container.t() | nil, | ||||
|           User.t(), | ||||
|           attrs :: map() | ||||
|         ) :: Changeset.t(new_ammo_group()) | ||||
|         ) :: changeset() | ||||
|   def create_changeset( | ||||
|         ammo_group, | ||||
|         %AmmoType{id: ammo_type_id}, | ||||
| @@ -69,9 +82,9 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|     |> change(ammo_type_id: ammo_type_id) | ||||
|     |> change(user_id: user_id) | ||||
|     |> change(container_id: container_id) | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged]) | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :staged, :ammo_type_id, :container_id, :user_id]) | ||||
|     |> validate_required([:count, :staged, :purchased_on, :ammo_type_id, :container_id, :user_id]) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -85,21 +98,30 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_ammo_group(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_ammo_group()) | ||||
|   def update_changeset(ammo_group, attrs) do | ||||
|   @spec update_changeset(t() | new_ammo_group(), attrs :: map(), User.t()) :: changeset() | ||||
|   def update_changeset(ammo_group, attrs, user) do | ||||
|     ammo_group | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged]) | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :container_id]) | ||||
|     |> validate_number(:count, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:count, :staged]) | ||||
|     |> validate_container_id(user) | ||||
|     |> validate_required([:count, :staged, :purchased_on, :container_id]) | ||||
|   end | ||||
|  | ||||
|   defp validate_container_id(changeset, user) do | ||||
|     container_id = changeset |> Changeset.get_field(:container_id) | ||||
|  | ||||
|     if container_id do | ||||
|       Containers.get_container!(container_id, user) | ||||
|     end | ||||
|  | ||||
|     changeset | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   This range changeset is used when "using up" ammo groups, and allows for | ||||
|   updating the count to 0 | ||||
|   """ | ||||
|   @spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_ammo_group()) | ||||
|   @spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: changeset() | ||||
|   def range_changeset(ammo_group, attrs) do | ||||
|     ammo_group | ||||
|     |> cast(attrs, [:count, :staged]) | ||||
|   | ||||
| @@ -11,6 +11,31 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :name, | ||||
|              :desc, | ||||
|              :bullet_type, | ||||
|              :bullet_core, | ||||
|              :cartridge, | ||||
|              :caliber, | ||||
|              :case_material, | ||||
|              :jacket_type, | ||||
|              :muzzle_velocity, | ||||
|              :powder_type, | ||||
|              :powder_grains_per_charge, | ||||
|              :grains, | ||||
|              :pressure, | ||||
|              :primer_type, | ||||
|              :firing_type, | ||||
|              :tracer, | ||||
|              :incendiary, | ||||
|              :blank, | ||||
|              :corrosive, | ||||
|              :manufacturer, | ||||
|              :upc | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "ammo_types" do | ||||
| @@ -77,6 +102,7 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|         } | ||||
|   @type new_ammo_type :: %AmmoType{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_type()) | ||||
|  | ||||
|   @spec changeset_fields() :: [atom()] | ||||
|   defp changeset_fields, | ||||
| @@ -105,8 +131,7 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|     ] | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: | ||||
|           Changeset.t(new_ammo_type()) | ||||
|   @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(ammo_type, %User{id: user_id}, attrs) do | ||||
|     ammo_type | ||||
|     |> change(user_id: user_id) | ||||
| @@ -115,8 +140,7 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_ammo_type()) | ||||
|   @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(ammo_type, attrs) do | ||||
|     ammo_type | ||||
|     |> cast(attrs, changeset_fields()) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ defmodule Cannery.Application do | ||||
|   @moduledoc false | ||||
|  | ||||
|   use Application | ||||
|   alias Cannery.Logger | ||||
|  | ||||
|   @impl true | ||||
|   def start(_type, _args) do | ||||
| @@ -17,16 +18,24 @@ defmodule Cannery.Application do | ||||
|       # Start the Endpoint (http/https) | ||||
|       CanneryWeb.Endpoint, | ||||
|       # Add Oban | ||||
|       {Oban, oban_config()} | ||||
|       {Oban, oban_config()}, | ||||
|       Cannery.Repo.Migrator | ||||
|       # Start a worker by calling: Cannery.Worker.start_link(arg) | ||||
|       # {Cannery.Worker, arg} | ||||
|     ] | ||||
|  | ||||
|     # Automatically migrate on start in prod | ||||
|     children = | ||||
|       if Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate], | ||||
|         do: children ++ [Cannery.Repo.Migrator], | ||||
|         else: children | ||||
|     # Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||
|     :ok = | ||||
|       :telemetry.attach_many( | ||||
|         "oban-logger", | ||||
|         [ | ||||
|           [:oban, :job, :exception], | ||||
|           [:oban, :job, :start], | ||||
|           [:oban, :job, :stop] | ||||
|         ], | ||||
|         &Logger.handle_event/4, | ||||
|         [] | ||||
|       ) | ||||
|  | ||||
|     # See https://hexdocs.pm/elixir/Supervisor.html | ||||
|     # for other strategies and supported options | ||||
|   | ||||
| @@ -17,16 +17,55 @@ defmodule Cannery.Containers do | ||||
|       iex> list_containers(%User{id: 123}) | ||||
|       [%Container{}, ...] | ||||
|  | ||||
|       iex> list_containers("cool", %User{id: 123}) | ||||
|       [%Container{name: "my cool container"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_containers(User.t()) :: [Container.t()] | ||||
|   def list_containers(%User{id: user_id}) do | ||||
|     Repo.all( | ||||
|       from c in Container, | ||||
|         left_join: t in assoc(c, :tags), | ||||
|         left_join: ag in assoc(c, :ammo_groups), | ||||
|         where: c.user_id == ^user_id, | ||||
|         order_by: c.name, | ||||
|         preload: [tags: t, ammo_groups: ag] | ||||
|   @spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()] | ||||
|   def list_containers(search \\ nil, %User{id: user_id}) do | ||||
|     from(c in Container, | ||||
|       as: :c, | ||||
|       left_join: t in assoc(c, :tags), | ||||
|       as: :t, | ||||
|       left_join: ag in assoc(c, :ammo_groups), | ||||
|       as: :ag, | ||||
|       where: c.user_id == ^user_id, | ||||
|       order_by: c.name, | ||||
|       preload: [tags: t, ammo_groups: ag] | ||||
|     ) | ||||
|     |> list_containers_search(search) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   defp list_containers_search(query, nil), do: query | ||||
|   defp list_containers_search(query, ""), do: query | ||||
|  | ||||
|   defp list_containers_search(query, search) do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     query | ||||
|     |> where( | ||||
|       [c: c, t: t], | ||||
|       fragment( | ||||
|         "? @@ websearch_to_tsquery('english', ?)", | ||||
|         c.search, | ||||
|         ^trimmed_search | ||||
|       ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           t.search, | ||||
|           ^trimmed_search | ||||
|         ) | ||||
|     ) | ||||
|     |> order_by( | ||||
|       [c: c], | ||||
|       desc: | ||||
|         fragment( | ||||
|           "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|           c.search, | ||||
|           ^trimmed_search | ||||
|         ) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
| @@ -89,7 +128,7 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   """ | ||||
|   @spec create_container(attrs :: map(), User.t()) :: | ||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.new_container())} | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def create_container(attrs, %User{} = user) do | ||||
|     %Container{} |> Container.create_changeset(user, attrs) |> Repo.insert() | ||||
|   end | ||||
| @@ -107,7 +146,7 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   """ | ||||
|   @spec update_container(Container.t(), User.t(), attrs :: map()) :: | ||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.t())} | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do | ||||
|     container |> Container.update_changeset(attrs) |> Repo.update() | ||||
|   end | ||||
| @@ -125,7 +164,7 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   """ | ||||
|   @spec delete_container(Container.t(), User.t()) :: | ||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.t())} | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do | ||||
|     Repo.one( | ||||
|       from ag in AmmoGroup, | ||||
| @@ -212,6 +251,7 @@ defmodule Cannery.Containers do | ||||
|     container | ||||
|     |> Repo.preload(:ammo_groups) | ||||
|     |> Map.fetch!(:ammo_groups) | ||||
|     |> Enum.reject(fn %{count: count} -> count == 0 end) | ||||
|     |> Enum.count() | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -9,6 +9,15 @@ defmodule Cannery.Containers.Container do | ||||
|   alias Cannery.Containers.{Container, ContainerTag} | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :name, | ||||
|              :desc, | ||||
|              :location, | ||||
|              :type, | ||||
|              :tags | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "containers" do | ||||
| @@ -40,10 +49,10 @@ defmodule Cannery.Containers.Container do | ||||
|         } | ||||
|   @type new_container :: %Container{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_container()) | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_container(), User.t(), attrs :: map()) :: | ||||
|           Changeset.t(new_container()) | ||||
|   @spec create_changeset(new_container(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(container, %User{id: user_id}, attrs) do | ||||
|     container | ||||
|     |> change(user_id: user_id) | ||||
| @@ -52,8 +61,7 @@ defmodule Cannery.Containers.Container do | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_container(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_container()) | ||||
|   @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(container, attrs) do | ||||
|     container | ||||
|     |> cast(attrs, [:name, :desc, :type, :location]) | ||||
|   | ||||
| @@ -29,10 +29,10 @@ defmodule Cannery.Containers.ContainerTag do | ||||
|         } | ||||
|   @type new_container_tag :: %ContainerTag{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_container_tag()) | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: | ||||
|           Changeset.t(new_container_tag()) | ||||
|   @spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: changeset() | ||||
|   def create_changeset( | ||||
|         container_tag, | ||||
|         %Tag{id: tag_id, user_id: user_id}, | ||||
|   | ||||
| @@ -1,156 +0,0 @@ | ||||
| defmodule Cannery.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, Invites.Invite, Repo} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   @invite_token_length 20 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of invites. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_invites(%User{id: 123, role: :admin}) | ||||
|       [%Invite{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_invites(User.t()) :: [Invite.t()] | ||||
|   def list_invites(%User{role: :admin}) do | ||||
|     Repo.all(from i in Invite, order_by: i.name) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single invite. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite!(456, %User{id: 123, role: :admin}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||
|   def get_invite!(id, %User{role: :admin}) do | ||||
|     Repo.get!(Invite, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a valid invite or nil based on the attempted token | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite_by_token("valid_token") | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite_by_token("invalid_token") | ||||
|       nil | ||||
|   """ | ||||
|   @spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil | ||||
|   def get_invite_by_token(nil), do: nil | ||||
|   def get_invite_by_token(""), do: nil | ||||
|  | ||||
|   def get_invite_by_token(token) do | ||||
|     Repo.one( | ||||
|       from(i in Invite, | ||||
|         where: i.token == ^token and i.disabled_at |> is_nil() | ||||
|       ) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||
|   completely used. | ||||
|   """ | ||||
|   @spec use_invite!(Invite.t()) :: Invite.t() | ||||
|   def use_invite!(%Invite{uses_left: nil} = invite), do: invite | ||||
|  | ||||
|   def use_invite!(%Invite{uses_left: uses_left} = invite) do | ||||
|     new_uses_left = uses_left - 1 | ||||
|  | ||||
|     attrs = | ||||
|       if new_uses_left <= 0 do | ||||
|         now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|         %{"uses_left" => 0, "disabled_at" => now} | ||||
|       else | ||||
|         %{"uses_left" => new_uses_left} | ||||
|       end | ||||
|  | ||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_invite(User.t(), attrs :: map()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())} | ||||
|   def create_invite(%User{role: :admin} = user, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     %Invite{} |> Invite.create_changeset(user, token, attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def update_invite(invite, attrs, %User{role: :admin}), | ||||
|     do: invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite(Invite.t(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||
|   def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!() | ||||
| end | ||||
							
								
								
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| defmodule Cannery.Logger do | ||||
|   @moduledoc """ | ||||
|   Custom logger for telemetry events | ||||
|  | ||||
|   Oban implementation taken from | ||||
|   https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||
|   """ | ||||
|  | ||||
|   require Logger | ||||
|  | ||||
|   def handle_event([:oban, :job, :exception], measure, %{stacktrace: stacktrace} = meta, _config) do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.error(meta.reason, data: data) | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Started oban job", data: data) | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :stop], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Finished oban job", data: data) | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:event, unhandled_event) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.warning("Unhandled oban job event", data: data) | ||||
|   end | ||||
|  | ||||
|   def handle_event(unhandled_event, measure, meta, config) do | ||||
|     data = | ||||
|       pretty_encode(%{ | ||||
|         event: unhandled_event, | ||||
|         meta: meta, | ||||
|         measurements: measure, | ||||
|         config: config | ||||
|       }) | ||||
|  | ||||
|     Logger.warning("Unhandled telemetry event", data: data) | ||||
|   end | ||||
|  | ||||
|   defp get_oban_job_data(%{job: job}, measure) do | ||||
|     %{ | ||||
|       job: job |> Map.take([:id, :args, :meta, :queue, :worker]), | ||||
|       measurements: measure | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   defp pretty_encode(data) do | ||||
|     data | ||||
|     |> Jason.encode!() | ||||
|     |> Jason.Formatter.pretty_print() | ||||
|   end | ||||
| end | ||||
| @@ -11,12 +11,15 @@ defmodule Cannery.Repo.Migrator do | ||||
|   end | ||||
|  | ||||
|   def init(_opts) do | ||||
|     migrate!() | ||||
|     {:ok, nil} | ||||
|     {:ok, if(automigrate_enabled?(), do: migrate!())} | ||||
|   end | ||||
|  | ||||
|   def migrate! do | ||||
|     path = Application.app_dir(:cannery, "priv/repo/migrations") | ||||
|     Ecto.Migrator.run(Cannery.Repo, path, :up, all: true) | ||||
|   end | ||||
|  | ||||
|   defp automigrate_enabled? do | ||||
|     Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate] | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -6,7 +6,6 @@ defmodule Cannery.Tags do | ||||
|   import Ecto.Query, warn: false | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts.User, Repo, Tags.Tag} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of tags. | ||||
| @@ -16,11 +15,38 @@ defmodule Cannery.Tags do | ||||
|       iex> list_tags(%User{id: 123}) | ||||
|       [%Tag{}, ...] | ||||
|  | ||||
|       iex> list_tags("cool", %User{id: 123}) | ||||
|       [%Tag{name: "my cool tag"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_tags(User.t()) :: [Tag.t()] | ||||
|   def list_tags(%{id: user_id}), | ||||
|   @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] | ||||
|   def list_tags(search \\ nil, user) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from t in Tag, | ||||
|         where: t.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "search @@ websearch_to_tsquery('english', ?)", | ||||
|             ^trimmed_search | ||||
|           ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)", | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single tag. | ||||
|  | ||||
| @@ -73,7 +99,7 @@ defmodule Cannery.Tags do | ||||
|  | ||||
|   """ | ||||
|   @spec create_tag(attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Changeset.t(Tag.new_tag())} | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def create_tag(attrs, %User{} = user), | ||||
|     do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert() | ||||
|  | ||||
| @@ -90,7 +116,7 @@ defmodule Cannery.Tags do | ||||
|  | ||||
|   """ | ||||
|   @spec update_tag(Tag.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Changeset.t(Tag.t())} | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}), | ||||
|     do: tag |> Tag.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
| @@ -106,7 +132,7 @@ defmodule Cannery.Tags do | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Changeset.t(Tag.t())} | ||||
|   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete() | ||||
|  | ||||
|   @doc """ | ||||
| @@ -120,18 +146,4 @@ defmodule Cannery.Tags do | ||||
|   """ | ||||
|   @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() | ||||
|   def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!() | ||||
|  | ||||
|   @doc """ | ||||
|   Get a random tag bg_color in `#ffffff` hex format | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> random_color() | ||||
|       "#cc0066" | ||||
|   """ | ||||
|   @spec random_bg_color() :: <<_::7>> | ||||
|   def random_bg_color do | ||||
|     ["#cc0066", "#ff6699", "#6666ff", "#0066cc", "#00cc66", "#669900", "#ff9900", "#996633"] | ||||
|     |> Enum.random() | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -9,6 +9,13 @@ defmodule Cannery.Tags.Tag do | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Tags.Tag} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :name, | ||||
|              :bg_color, | ||||
|              :text_color | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "tags" do | ||||
| @@ -33,9 +40,10 @@ defmodule Cannery.Tags.Tag do | ||||
|         } | ||||
|   @type new_tag() :: %Tag{} | ||||
|   @type id() :: UUID.t() | ||||
|   @type changeset() :: Changeset.t(t() | new_tag()) | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_tag(), User.t(), attrs :: map()) :: Changeset.t(new_tag()) | ||||
|   @spec create_changeset(new_tag(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(tag, %User{id: user_id}, attrs) do | ||||
|     tag | ||||
|     |> change(user_id: user_id) | ||||
| @@ -44,7 +52,7 @@ defmodule Cannery.Tags.Tag do | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_tag(), attrs :: map()) :: Changeset.t(t() | new_tag()) | ||||
|   @spec update_changeset(t() | new_tag(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(tag, attrs) do | ||||
|     tag | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|   | ||||
| @@ -72,16 +72,14 @@ defmodule CanneryWeb do | ||||
|     quote do | ||||
|       use Phoenix.Router | ||||
|  | ||||
|       import Phoenix.{Controller, LiveView.Router} | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Plug.Conn | ||||
|       import Phoenix.Controller | ||||
|       import Phoenix.LiveView.Router | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def channel do | ||||
|     quote do | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       use Phoenix.Channel | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import CanneryWeb.Gettext | ||||
| @@ -95,15 +93,11 @@ defmodule CanneryWeb do | ||||
|       use Phoenix.HTML | ||||
|  | ||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Phoenix.LiveView.Helpers | ||||
|  | ||||
|       # Import basic rendering functionality (render, render_layout, etc) | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Phoenix.View | ||||
|  | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers} | ||||
|       import Phoenix.{Component, View} | ||||
|  | ||||
|       alias CanneryWeb.Endpoint | ||||
|       alias CanneryWeb.Router.Helpers, as: Routes | ||||
|     end | ||||
|   end | ||||
|   | ||||
| @@ -5,7 +5,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} | ||||
|   alias Phoenix.LiveView.Socket | ||||
|   alias Phoenix.LiveView.{JS, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
| @@ -54,7 +54,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|       |> case do | ||||
|         {:ok, _shot_group} -> | ||||
|           prompt = dgettext("prompts", "Shots recorded successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Ecto.Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|   </h2> | ||||
|  | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="shot-group-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
| @@ -12,19 +12,27 @@ | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :ammo_left, | ||||
|       min: 0, | ||||
|       max: @ammo_group.count - 1, | ||||
|       placeholder: 0, | ||||
|       class: "input input-primary col-span-2" | ||||
|       placeholder: gettext("Rounds left"), | ||||
|       class: "input input-primary" | ||||
|     ) %> | ||||
|     <button | ||||
|       type="button" | ||||
|       class="mx-2 my-1 text-sm btn btn-primary" | ||||
|       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")} | ||||
|     > | ||||
|       <%= gettext("Used up!") %> | ||||
|     </button> | ||||
|     <%= error_tag(f, :ammo_left, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
| @@ -35,7 +43,7 @@ | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :date, gettext("Date (UTC)"), class: "title text-lg text-primary-600") %> | ||||
|     <%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %> | ||||
|     <%= date_input(f, :date, | ||||
|       class: "input input-primary col-span-2", | ||||
|       phx_update: "ignore", | ||||
|   | ||||
| @@ -4,42 +4,63 @@ defmodule CanneryWeb.Components.AmmoGroupCard do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Repo | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   def ammo_group_card(assigns) do | ||||
|     assigns = assigns |> assign(:ammo_group, assigns.ammo_group |> Repo.preload(:ammo_type)) | ||||
|   attr :ammo_group, AmmoGroup, required: true | ||||
|   attr :show_container, :boolean, default: false | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   def ammo_group_card(%{ammo_group: ammo_group} = assigns) do | ||||
|     assigns = | ||||
|       %{show_container: show_container} = assigns |> assign_new(:show_container, fn -> false end) | ||||
|  | ||||
|     preloads = if show_container, do: [:ammo_type, :container], else: [:ammo_type] | ||||
|     ammo_group = ammo_group |> Repo.preload(preloads) | ||||
|  | ||||
|     assigns = assigns |> assign(:ammo_group, ammo_group) | ||||
|  | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"ammo_group-#{@ammo_group.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center | ||||
|             border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|             transition-all duration-300 ease-in-out" | ||||
|       class="mx-4 my-2 px-8 py-4 | ||||
|         flex flex-col justify-center items-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <%= live_redirect to: Routes.ammo_group_show_path(Endpoint, :show, @ammo_group), | ||||
|                     class: "mb-2 link" do %> | ||||
|       <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link"> | ||||
|         <h1 class="title text-xl title-primary-500"> | ||||
|           <%= @ammo_group.ammo_type.name %> | ||||
|         </h1> | ||||
|       <% end %> | ||||
|       </.link> | ||||
|  | ||||
|       <div class="flex flex-col justify-center items-center"> | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Count:") %> | ||||
|           <%= @ammo_group.count %> | ||||
|           <%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %> | ||||
|         </span> | ||||
|  | ||||
|         <%= if @ammo_group.notes do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Notes:") %> | ||||
|             <%= @ammo_group.notes %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|         <span | ||||
|           :if={@ammo_group |> Ammo.get_original_count() != @ammo_group.count} | ||||
|           class="rounded-lg title text-lg" | ||||
|         > | ||||
|           <%= gettext("Original Count:") %> | ||||
|           <%= @ammo_group |> Ammo.get_original_count() %> | ||||
|         </span> | ||||
|  | ||||
|         <span :if={@ammo_group.notes} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Notes:") %> | ||||
|           <%= @ammo_group.notes %> | ||||
|         </span> | ||||
|  | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Added on:") %> | ||||
|           <%= @ammo_group.inserted_at |> display_datetime() %> | ||||
|           <%= gettext("Purchased on:") %> | ||||
|           <.date date={@ammo_group.purchased_on} /> | ||||
|         </span> | ||||
|  | ||||
|         <span :if={@ammo_group |> Ammo.get_last_used_shot_group()} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Last used on:") %> | ||||
|           <.date date={@ammo_group |> Ammo.get_last_used_shot_group() |> Map.get(:date)} /> | ||||
|         </span> | ||||
|  | ||||
|         <%= if @ammo_group.price_paid do %> | ||||
| @@ -49,14 +70,33 @@ defmodule CanneryWeb.Components.AmmoGroupCard do | ||||
|               amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2) | ||||
|             ) %> | ||||
|           </span> | ||||
|  | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("CPR:") %> | ||||
|             <%= gettext("$%{amount}", | ||||
|               amount: @ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2) | ||||
|             ) %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|  | ||||
|         <span :if={@show_container and @ammo_group.container} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Container:") %> | ||||
|  | ||||
|           <.link | ||||
|             navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)} | ||||
|             class="link" | ||||
|           > | ||||
|             <%= @ammo_group.container.name %> | ||||
|           </.link> | ||||
|         </span> | ||||
|       </div> | ||||
|  | ||||
|       <%= if assigns |> Map.has_key?(:inner_block) do %> | ||||
|         <div class="mt-4 flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|       <div | ||||
|         :if={assigns |> Map.has_key?(:inner_block)} | ||||
|         class="mt-4 flex space-x-4 justify-center items-center" | ||||
|       > | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
							
								
								
									
										234
									
								
								lib/cannery_web/components/ammo_group_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								lib/cannery_web/components/ammo_group_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,234 @@ | ||||
| defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of ammo groups | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:ammo_groups) => [AmmoGroup.t()], | ||||
|             optional(:ammo_type) => Rendered.t(), | ||||
|             optional(:range) => Rendered.t(), | ||||
|             optional(:container) => Rendered.t(), | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, ammo_groups: _ammo_group, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:ammo_type, fn -> [] end) | ||||
|       |> assign_new(:range, fn -> [] end) | ||||
|       |> assign_new(:container, fn -> [] end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_ammo_groups() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_groups( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              ammo_groups: ammo_groups, | ||||
|              current_user: current_user, | ||||
|              ammo_type: ammo_type, | ||||
|              range: range, | ||||
|              container: container, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       if actions == [] do | ||||
|         [] | ||||
|       else | ||||
|         [%{label: nil, key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Purchased on"), key: :purchased_on}, | ||||
|       %{label: gettext("Last used on"), key: :used_up_on} | columns | ||||
|     ] | ||||
|  | ||||
|     columns = | ||||
|       if container == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Container"), key: :container} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = | ||||
|       if range == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Range"), key: :range} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Count"), key: :count}, | ||||
|       %{label: gettext("Original Count"), key: :original_count}, | ||||
|       %{label: gettext("Price paid"), key: :price_paid}, | ||||
|       %{label: gettext("CPR"), key: :cpr}, | ||||
|       %{label: gettext("% left"), key: :remaining}, | ||||
|       %{label: gettext("Notes"), key: :notes} | ||||
|       | columns | ||||
|     ] | ||||
|  | ||||
|     columns = | ||||
|       if ammo_type == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Ammo type"), key: :ammo_type} | columns] | ||||
|       end | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       ammo_type: ammo_type, | ||||
|       columns: columns, | ||||
|       container: container, | ||||
|       actions: actions, | ||||
|       range: range | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       ammo_groups | ||||
|       |> Repo.preload([:ammo_type, :container]) | ||||
|       |> Enum.map(fn ammo_group -> | ||||
|         ammo_group |> get_row_data_for_ammo_group(extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map() | ||||
|   defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do | ||||
|     ammo_group = ammo_group |> Repo.preload([:ammo_type, :container]) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_value_for_key(key, ammo_group, additional_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(atom(), AmmoGroup.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key( | ||||
|          :ammo_type, | ||||
|          %{ammo_type: %{name: ammo_type_name} = ammo_type}, | ||||
|          %{ammo_type: ammo_type_block} | ||||
|        ) do | ||||
|     assigns = %{ammo_type: ammo_type, ammo_type_block: ammo_type_block} | ||||
|  | ||||
|     {ammo_type_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@ammo_type_block, @ammo_type) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil} | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), | ||||
|     do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2)) | ||||
|  | ||||
|   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on}, _additional_data) do | ||||
|     assigns = %{purchased_on: purchased_on} | ||||
|  | ||||
|     {purchased_on, | ||||
|      ~H""" | ||||
|      <.date date={@purchased_on} /> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:used_up_on, ammo_group, _additional_data) do | ||||
|     last_shot_group_date = | ||||
|       case ammo_group |> Ammo.get_last_used_shot_group() do | ||||
|         %{date: last_shot_group_date} -> last_shot_group_date | ||||
|         _no_shot_groups -> nil | ||||
|       end | ||||
|  | ||||
|     assigns = %{last_shot_group_date: last_shot_group_date} | ||||
|  | ||||
|     {last_shot_group_date, | ||||
|      ~H""" | ||||
|      <%= if @last_shot_group_date do %> | ||||
|        <.date date={@last_shot_group_date} /> | ||||
|      <% else %> | ||||
|        <%= gettext("Never used") %> | ||||
|      <% end %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:range, %{staged: staged} = ammo_group, %{range: range}) do | ||||
|     assigns = %{range: range, ammo_group: ammo_group} | ||||
|  | ||||
|     {staged, | ||||
|      ~H""" | ||||
|      <%= render_slot(@range, @ammo_group) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:remaining, ammo_group, _additional_data), | ||||
|     do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining()) | ||||
|  | ||||
|   defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, ammo_group: ammo_group} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @ammo_group) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil} | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :container, | ||||
|          %{container: %{name: container_name}} = ammo_group, | ||||
|          %{container: container} | ||||
|        ) do | ||||
|     assigns = %{container: container, ammo_group: ammo_group} | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@container, @ammo_group) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:original_count, ammo_group, _additional_data), | ||||
|     do: ammo_group |> Ammo.get_original_count() | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), | ||||
|     do: gettext("No cost information") | ||||
|  | ||||
|   defp get_value_for_key(:cpr, ammo_group, _additional_data) do | ||||
|     gettext("$%{amount}", | ||||
|       amount: ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:count, %{count: count}, _additional_data), | ||||
|     do: if(count == 0, do: gettext("Empty"), else: count) | ||||
|  | ||||
|   defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key) | ||||
| end | ||||
							
								
								
									
										209
									
								
								lib/cannery_web/components/ammo_type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								lib/cannery_web/components/ammo_type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,209 @@ | ||||
| defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of ammo type | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:show_used) => boolean(), | ||||
|             optional(:ammo_types) => [AmmoType.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, ammo_types: _ammo_types, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:show_used, fn -> false end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_ammo_types() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_types( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              ammo_types: ammo_types, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       [ | ||||
|         %{label: gettext("Name"), key: :name, type: :name}, | ||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||
|         %{label: gettext("Bullet core"), key: :bullet_core, type: :string}, | ||||
|         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, | ||||
|         %{label: gettext("Caliber"), key: :caliber, type: :string}, | ||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||
|         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||
|         %{label: gettext("Powder type"), key: :powder_type, type: :string}, | ||||
|         %{ | ||||
|           label: gettext("Powder grains per charge"), | ||||
|           key: :powder_grains_per_charge, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, | ||||
|         %{label: gettext("Pressure"), key: :pressure, type: :string}, | ||||
|         %{label: gettext("Primer type"), key: :primer_type, type: :string}, | ||||
|         %{label: gettext("Firing type"), key: :firing_type, type: :string}, | ||||
|         %{label: gettext("Tracer"), key: :tracer, type: :boolean}, | ||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :boolean}, | ||||
|         %{label: gettext("Blank"), key: :blank, type: :boolean}, | ||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :boolean}, | ||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string}, | ||||
|         %{label: gettext("UPC"), key: "upc", type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = if type == :boolean, do: false, else: nil | ||||
|  | ||||
|         ammo_types | ||||
|         |> Enum.any?(fn ammo_type -> | ||||
|           not (ammo_type |> Map.get(key) == default_value) | ||||
|         end) | ||||
|       end) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Rounds"), key: :round_count, type: :round_count} | ||||
|       ]) | ||||
|       |> Kernel.++( | ||||
|         if show_used do | ||||
|           [ | ||||
|             %{ | ||||
|               label: gettext("Used rounds"), | ||||
|               key: :used_round_count, | ||||
|               type: :used_round_count | ||||
|             }, | ||||
|             %{ | ||||
|               label: gettext("Total ever rounds"), | ||||
|               key: :historical_round_count, | ||||
|               type: :historical_round_count | ||||
|             } | ||||
|           ] | ||||
|         else | ||||
|           [] | ||||
|         end | ||||
|       ) | ||||
|       |> Kernel.++([%{label: gettext("Packs"), key: :ammo_count, type: :ammo_count}]) | ||||
|       |> Kernel.++( | ||||
|         if show_used do | ||||
|           [ | ||||
|             %{ | ||||
|               label: gettext("Used packs"), | ||||
|               key: :used_ammo_count, | ||||
|               type: :used_ammo_count | ||||
|             }, | ||||
|             %{ | ||||
|               label: gettext("Total ever packs"), | ||||
|               key: :historical_ammo_count, | ||||
|               type: :historical_ammo_count | ||||
|             } | ||||
|           ] | ||||
|         else | ||||
|           [] | ||||
|         end | ||||
|       ) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid}, | ||||
|         %{label: nil, key: "actions", type: :actions, sortable: false} | ||||
|       ]) | ||||
|  | ||||
|     extra_data = %{actions: actions, current_user: current_user} | ||||
|  | ||||
|     rows = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn ammo_type -> | ||||
|         ammo_type |> get_ammo_type_values(columns, extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_values(ammo_type, columns, extra_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key, type: type} -> | ||||
|       {key, get_ammo_type_value(type, key, ammo_type, extra_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:boolean, key, ammo_type, _other_data), | ||||
|     do: ammo_type |> Map.get(key) |> humanize() | ||||
|  | ||||
|   defp get_ammo_type_value(:round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:historical_round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|        do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:used_round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) | ||||
|  | ||||
|   defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, %{current_user: current_user}) do | ||||
|     case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do | ||||
|       nil -> gettext("No cost information") | ||||
|       count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:name, _key, ammo_type, _other_data) do | ||||
|     assigns = %{ammo_type: ammo_type} | ||||
|  | ||||
|     ~H""" | ||||
|     <.link | ||||
|       navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} | ||||
|       class="link" | ||||
|       data-qa={"view-name-#{@ammo_type.id}"} | ||||
|     > | ||||
|       <%= @ammo_type.name %> | ||||
|     </.link> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, ammo_type: ammo_type} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @ammo_type) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil | ||||
|  | ||||
|   defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key) | ||||
| end | ||||
| @@ -5,45 +5,50 @@ defmodule CanneryWeb.Components.ContainerCard do | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   import CanneryWeb.Components.TagCard | ||||
|   alias Cannery.{Containers, Repo} | ||||
|   alias Cannery.{Containers, Containers.Container, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Phoenix.LiveView.Rendered | ||||
|  | ||||
|   attr :container, Container, required: true | ||||
|   slot(:tag_actions) | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @spec container_card(assigns :: map()) :: Rendered.t() | ||||
|   def container_card(%{container: container} = assigns) do | ||||
|     assigns = assigns |> Map.put(:container, container |> Repo.preload([:tags, :ammo_groups])) | ||||
|     assigns = | ||||
|       assigns | ||||
|       |> assign(container: container |> Repo.preload([:tags, :ammo_groups])) | ||||
|       |> assign_new(:tag_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"container-#{@container.id}"} | ||||
|       class="overflow-hidden max-w-full mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|       class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 | ||||
|         flex flex-col justify-center items-center space-y-4 | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <div class="max-w-full mb-4 flex flex-col justify-center items-center space-y-2"> | ||||
|         <%= live_redirect to: Routes.container_show_path(Endpoint, :show, @container), | ||||
|                       class: "link" do %> | ||||
|         <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||
|           <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||
|             <%= @container.name %> | ||||
|           </h1> | ||||
|         <% end %> | ||||
|         </.link> | ||||
|  | ||||
|         <%= if @container.desc do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Description:") %> | ||||
|             <%= @container.desc %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|         <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Description:") %> | ||||
|           <%= @container.desc %> | ||||
|         </span> | ||||
|  | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Type:") %> | ||||
|           <%= @container.type %> | ||||
|         </span> | ||||
|  | ||||
|         <%= if @container.location do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Location:") %> | ||||
|             <%= @container.location %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|         <span :if={@container.location} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Location:") %> | ||||
|           <%= @container.location %> | ||||
|         </span> | ||||
|  | ||||
|         <%= unless @container.ammo_groups |> Enum.empty?() do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
| @@ -58,23 +63,18 @@ defmodule CanneryWeb.Components.ContainerCard do | ||||
|         <% end %> | ||||
|  | ||||
|         <div class="flex flex-wrap justify-center items-center"> | ||||
|           <%= unless @container.tags |> Enum.empty?() do %> | ||||
|             <%= for tag <- @container.tags do %> | ||||
|               <.simple_tag_card tag={tag} /> | ||||
|             <% end %> | ||||
|           <% end %> | ||||
|           <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||
|  | ||||
|           <%= if assigns |> Map.has_key?(:tag_actions) do %> | ||||
|             <%= render_slot(@tag_actions) %> | ||||
|           <% end %> | ||||
|           <%= render_slot(@tag_actions) %> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <%= if assigns |> Map.has_key?(:inner_block) do %> | ||||
|         <div class="flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|       <div | ||||
|         :if={assigns |> Map.has_key?(:inner_block)} | ||||
|         class="flex space-x-4 justify-center items-center" | ||||
|       > | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
							
								
								
									
										154
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of containers | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo} | ||||
|   alias CanneryWeb.Components.TagCard | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:containers) => [Container.t()], | ||||
|             optional(:tag_actions) => Rendered.t(), | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, containers: _containers, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:tag_actions, fn -> [] end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_containers() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_containers( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              containers: containers, | ||||
|              current_user: current_user, | ||||
|              tag_actions: tag_actions, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       [ | ||||
|         %{label: gettext("Name"), key: :name, type: :string}, | ||||
|         %{label: gettext("Description"), key: :desc, type: :string}, | ||||
|         %{label: gettext("Location"), key: :location, type: :string}, | ||||
|         %{label: gettext("Type"), key: :type, type: :string}, | ||||
|         %{label: gettext("Packs"), key: :packs, type: :integer}, | ||||
|         %{label: gettext("Rounds"), key: :rounds, type: :string}, | ||||
|         %{label: gettext("Tags"), key: :tags, type: :tags}, | ||||
|         %{label: nil, key: :actions, sortable: false, type: :actions} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = | ||||
|           case type do | ||||
|             :boolean -> false | ||||
|             _other_type -> nil | ||||
|           end | ||||
|  | ||||
|         containers | ||||
|         |> Enum.any?(fn container -> | ||||
|           type in [:tags, :actions] or not (container |> Map.get(key) == default_value) | ||||
|         end) | ||||
|       end) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       tag_actions: tag_actions, | ||||
|       actions: actions | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       containers | ||||
|       |> Enum.map(fn container -> | ||||
|         container |> get_row_data_for_container(columns, extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       columns: columns, | ||||
|       rows: rows | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map() | ||||
|   defp get_row_data_for_container(container, columns, extra_data) do | ||||
|     container = container |> Repo.preload([:ammo_groups, :tags]) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end) | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any() | ||||
|   defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do | ||||
|     assigns = %{id: id, container_name: container_name} | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <div class="flex flex-wrap justify-center items-center"> | ||||
|        <.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link"> | ||||
|          <%= @container_name %> | ||||
|        </.link> | ||||
|      </div> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:packs, container, _extra_data) do | ||||
|     container |> Containers.get_container_ammo_group_count!() | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:rounds, container, _extra_data) do | ||||
|     container |> Containers.get_container_rounds!() | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do | ||||
|     assigns = %{tag_actions: tag_actions, container: container} | ||||
|  | ||||
|     {container.tags |> Enum.map(fn %{name: name} -> name end), | ||||
|      ~H""" | ||||
|      <div class="flex flex-wrap justify-center items-center"> | ||||
|        <TagCard.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||
|  | ||||
|        <%= render_slot(@tag_actions, @container) %> | ||||
|      </div> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:actions, container, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, container: container} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @container) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(key, container, _extra_data), do: container |> Map.get(key) | ||||
| end | ||||
| @@ -4,9 +4,20 @@ defmodule CanneryWeb.Components.InviteCard do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Accounts.{Invite, Invites, User} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   def invite_card(assigns) do | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   def invite_card(%{invite: invite, current_user: current_user} = assigns) do | ||||
|     assigns = | ||||
|       assigns | ||||
|       |> assign(:use_count, Invites.get_use_count(invite, current_user)) | ||||
|       |> assign_new(:code_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|       border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
| @@ -17,8 +28,14 @@ defmodule CanneryWeb.Components.InviteCard do | ||||
|  | ||||
|       <%= if @invite.disabled_at |> is_nil() do %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= gettext("Uses Left:") %> | ||||
|           <%= @invite.uses_left || "Unlimited" %> | ||||
|           <%= if @invite.uses_left do %> | ||||
|             <%= gettext( | ||||
|               "Uses Left: %{uses_left_count}", | ||||
|               uses_left_count: @invite.uses_left | ||||
|             ) %> | ||||
|           <% else %> | ||||
|             <%= gettext("Uses Left: Unlimited") %> | ||||
|           <% end %> | ||||
|         </h2> | ||||
|       <% else %> | ||||
|         <h2 class="title text-md"> | ||||
| @@ -26,24 +43,27 @@ defmodule CanneryWeb.Components.InviteCard do | ||||
|         </h2> | ||||
|       <% end %> | ||||
|  | ||||
|       <.qr_code | ||||
|         content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|         filename={@invite.name} | ||||
|       /> | ||||
|  | ||||
|       <h2 :if={@use_count != 0} class="title text-md"> | ||||
|         <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> | ||||
|       </h2> | ||||
|  | ||||
|       <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|         <code | ||||
|           id={"code-#{@invite.id}"} | ||||
|           class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|         > | ||||
|           <%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %> | ||||
|         </code> | ||||
|  | ||||
|         <%= if @code_actions do %> | ||||
|           <%= render_slot(@code_actions) %> | ||||
|         <% end %> | ||||
|           phx-no-format | ||||
|         ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|         <%= render_slot(@code_actions) %> | ||||
|       </div> | ||||
|  | ||||
|       <%= if @inner_block do %> | ||||
|         <div class="flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|       <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
| @@ -22,7 +22,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|           assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     changeset = ammo_group |> AmmoGroup.update_changeset(%{}) | ||||
|     changeset = ammo_group |> AmmoGroup.update_changeset(%{}, current_user) | ||||
|  | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) | ||||
| @@ -52,7 +52,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|         {:ok, _ammo_group} -> | ||||
|           prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Ecto.Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
| @@ -77,7 +77,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|     ~H""" | ||||
|     <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"> | ||||
|         <%= gettext("Move ammo") %> | ||||
|         <%= dgettext("actions", "Move ammo") %> | ||||
|       </h2> | ||||
|  | ||||
|       <%= if @containers |> Enum.empty?() do %> | ||||
| @@ -86,10 +86,9 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|           <%= display_emoji("😔") %> | ||||
|         </h2> | ||||
|  | ||||
|         <%= live_patch(dgettext("actions", "Add another container!"), | ||||
|           to: Routes.container_index_path(Endpoint, :new), | ||||
|           class: "btn btn-primary" | ||||
|         ) %> | ||||
|         <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "Add another container!") %> | ||||
|         </.link> | ||||
|       <% else %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.TableComponent} | ||||
| @@ -107,7 +106,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|     containers | ||||
|     |> Enum.map(fn container -> | ||||
|       columns | ||||
|       |> Enum.into(%{}, fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end) | ||||
|       |> Map.new(fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end) | ||||
|     end) | ||||
|   end | ||||
|  | ||||
| @@ -122,7 +121,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|         class="btn btn-primary" | ||||
|         phx-click="move" | ||||
|         phx-target={@myself} | ||||
|         phx-value-container_id={container.id} | ||||
|         phx-value-container_id={@container.id} | ||||
|       > | ||||
|         <%= dgettext("actions", "Select") %> | ||||
|       </button> | ||||
|   | ||||
							
								
								
									
										121
									
								
								lib/cannery_web/components/shot_group_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								lib/cannery_web/components/shot_group_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of shot groups | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:shot_groups) => [ShotGroup.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, shot_groups: _shot_groups, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_shot_groups() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_shot_groups( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              shot_groups: shot_groups, | ||||
|              current_user: current_user, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = [ | ||||
|       %{label: gettext("Ammo"), key: :name}, | ||||
|       %{label: gettext("Rounds shot"), key: :count}, | ||||
|       %{label: gettext("Notes"), key: :notes}, | ||||
|       %{label: gettext("Date"), key: :date}, | ||||
|       %{label: nil, key: :actions, sortable: false} | ||||
|     ] | ||||
|  | ||||
|     extra_data = %{current_user: current_user, actions: actions} | ||||
|  | ||||
|     rows = | ||||
|       shot_groups | ||||
|       |> Enum.map(fn shot_group -> | ||||
|         shot_group |> get_row_data_for_shot_group(columns, extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       columns: columns, | ||||
|       rows: rows | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|         initial_key={:date} | ||||
|         initial_sort_mode={:desc} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) :: | ||||
|           map() | ||||
|   defp get_row_data_for_shot_group(shot_group, columns, extra_data) do | ||||
|     shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_row_value(key, shot_group, extra_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_row_value( | ||||
|          :name, | ||||
|          %{ammo_group: %{ammo_type: %{name: ammo_type_name} = ammo_group}}, | ||||
|          _extra_data | ||||
|        ) do | ||||
|     assigns = %{ammo_group: ammo_group, ammo_type_name: ammo_type_name} | ||||
|  | ||||
|     name_block = ~H""" | ||||
|     <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link"> | ||||
|       <%= @ammo_type_name %> | ||||
|     </.link> | ||||
|     """ | ||||
|  | ||||
|     {ammo_type_name, name_block} | ||||
|   end | ||||
|  | ||||
|   defp get_row_value(:date, %{date: _date} = assigns, _extra_data) do | ||||
|     ~H""" | ||||
|     <.date date={@date} /> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_row_value(:actions, shot_group, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, shot_group: shot_group} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @shot_group) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_row_value(key, shot_group, _extra_data), do: shot_group |> Map.get(key) | ||||
| end | ||||
| @@ -6,7 +6,7 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|     - `:columns`: An array of maps containing the following keys | ||||
|       - `:label`: A gettext'd or otherwise user-facing string label for the | ||||
|         column. Can be nil | ||||
|       - `:key`: A string key used for sorting | ||||
|       - `:key`: An atom key used for sorting | ||||
|       - `:class`: Extra classes to be applied to the column element, if desired. | ||||
|         Optional | ||||
|       - `:sortable`: If false, will prevent the user from sorting with it. | ||||
| @@ -21,6 +21,7 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Phoenix.LiveView.Socket | ||||
|   require Integer | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
| @@ -28,26 +29,50 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|             required(:columns) => | ||||
|               list(%{ | ||||
|                 required(:label) => String.t() | nil, | ||||
|                 required(:key) => String.t() | nil, | ||||
|                 required(:key) => atom() | nil, | ||||
|                 optional(:class) => String.t(), | ||||
|                 optional(:row_class) => String.t(), | ||||
|                 optional(:alternate_row_class) => String.t(), | ||||
|                 optional(:sortable) => false | ||||
|               }), | ||||
|             required(:rows) => | ||||
|               list(%{ | ||||
|                 (key :: String.t()) => any() | {custom_sort_value :: String.t(), value :: any()} | ||||
|                 (key :: atom()) => any() | {custom_sort_value :: String.t(), value :: any()} | ||||
|               }), | ||||
|             optional(:inital_key) => atom(), | ||||
|             optional(:initial_sort_mode) => atom(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{columns: columns, rows: rows} = assigns, socket) do | ||||
|     initial_key = columns |> List.first() |> Map.get(:key) | ||||
|     rows = rows |> Enum.sort_by(fn row -> row |> Map.get(initial_key) end, :asc) | ||||
|     initial_key = | ||||
|       if assigns |> Map.has_key?(:initial_key) do | ||||
|         assigns.initial_key | ||||
|       else | ||||
|         columns |> List.first(%{}) |> Map.get(:key) | ||||
|       end | ||||
|  | ||||
|     initial_sort_mode = | ||||
|       if assigns |> Map.has_key?(:initial_sort_mode) do | ||||
|         assigns.initial_sort_mode | ||||
|       else | ||||
|         :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode) | ||||
|  | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign(columns: columns, rows: rows, last_sort_key: initial_key, sort_mode: :asc) | ||||
|       |> assign( | ||||
|         columns: columns, | ||||
|         rows: rows, | ||||
|         last_sort_key: initial_key, | ||||
|         sort_mode: initial_sort_mode | ||||
|       ) | ||||
|       |> assign_new(:row_class, fn -> "bg-white" end) | ||||
|       |> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
| @@ -56,20 +81,19 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|   def handle_event( | ||||
|         "sort_by", | ||||
|         %{"sort-key" => key}, | ||||
|         %{assigns: %{rows: rows, last_sort_key: key, sort_mode: sort_mode}} = socket | ||||
|         %{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket | ||||
|       ) do | ||||
|     sort_mode = if sort_mode == :asc, do: :desc, else: :asc | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) | ||||
|     {:noreply, socket |> assign(sort_mode: sort_mode, rows: rows)} | ||||
|   end | ||||
|     key = key |> String.to_existing_atom() | ||||
|  | ||||
|   def handle_event( | ||||
|         "sort_by", | ||||
|         %{"sort-key" => key}, | ||||
|         %{assigns: %{rows: rows}} = socket | ||||
|       ) do | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, :asc) | ||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: :asc, rows: rows)} | ||||
|     sort_mode = | ||||
|       case {key, sort_mode} do | ||||
|         {^last_sort_key, :asc} -> :desc | ||||
|         {^last_sort_key, :desc} -> :asc | ||||
|         {_new_sort_key, _last_sort_mode} -> :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) | ||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do | ||||
|   | ||||
| @@ -1,31 +1,32 @@ | ||||
| <div class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-black"> | ||||
| <div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white"> | ||||
|   <table class="min-w-full table-auto text-center bg-white"> | ||||
|     <thead class="border-b border-primary-600"> | ||||
|       <tr> | ||||
|         <%= for %{key: key, label: label} = column <- @columns do %> | ||||
|           <%= if column |> Map.get(:sortable, true) do %> | ||||
|             <th class={"p-2 #{column[:class]}"}> | ||||
|             <th class={["p-2", column[:class]]}> | ||||
|               <span | ||||
|                 class="cursor-pointer" | ||||
|                 class="cursor-pointer flex justify-center items-center space-x-2" | ||||
|                 phx-click="sort_by" | ||||
|                 phx-value-sort-key={key} | ||||
|                 phx-target={@myself} | ||||
|               > | ||||
|                 <span class="underline"><%= label %></span> | ||||
|                 <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                 <span class={if @last_sort_key == key, do: "underline"}><%= label %></span> | ||||
|                 <%= if @last_sort_key == key do %> | ||||
|                   <%= case @sort_mode do %> | ||||
|                     <% :asc -> %> | ||||
|                       <i class="fas fa-sm fa-chevron-down"></i> | ||||
|                       <i class="w-0 float-right fas fa-sm fa-chevron-down"></i> | ||||
|                     <% :desc -> %> | ||||
|                       <i class="fas fa-sm fa-chevron-up"></i> | ||||
|                       <i class="w-0 float-right fas fa-sm fa-chevron-up"></i> | ||||
|                   <% end %> | ||||
|                 <% else %> | ||||
|                   <i class="fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                   <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                 <% end %> | ||||
|               </span> | ||||
|             </th> | ||||
|           <% else %> | ||||
|             <th class={"p-2 #{column[:class]}"}> | ||||
|             <th class={["p-2 cursor-not-allowed", column[:class]]}> | ||||
|               <%= label %> | ||||
|             </th> | ||||
|           <% end %> | ||||
| @@ -33,20 +34,19 @@ | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <%= for values <- @rows do %> | ||||
|         <tr> | ||||
|           <%= for %{key: key} = value <- @columns do %> | ||||
|             <td class={"p-2 #{value[:class]}"}> | ||||
|               <%= case values |> Map.get(key) do %> | ||||
|                 <% {_custom_sort_value, value} -> %> | ||||
|                   <%= value %> | ||||
|                 <% value -> %> | ||||
|                   <%= value %> | ||||
|               <% end %> | ||||
|             </td> | ||||
|       <tr | ||||
|         :for={{values, i} <- @rows |> Enum.with_index()} | ||||
|         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} | ||||
|       > | ||||
|         <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> | ||||
|           <%= case values |> Map.get(key) do %> | ||||
|             <% {_custom_sort_value, value} -> %> | ||||
|               <%= value %> | ||||
|             <% value -> %> | ||||
|               <%= value %> | ||||
|           <% end %> | ||||
|         </tr> | ||||
|       <% end %> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
|   | ||||
| @@ -4,14 +4,18 @@ defmodule CanneryWeb.Components.TagCard do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Tags.Tag | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def tag_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"tag-#{@tag.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 space-x-4 flex justify-center items-center | ||||
|           border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|           transition-all duration-300 ease-in-out" | ||||
|       class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <.simple_tag_card tag={@tag} /> | ||||
|       <%= render_slot(@inner_block) %> | ||||
| @@ -19,6 +23,8 @@ defmodule CanneryWeb.Components.TagCard do | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|  | ||||
|   def simple_tag_card(assigns) do | ||||
|     ~H""" | ||||
|     <h1 | ||||
|   | ||||
| @@ -16,16 +16,17 @@ defmodule CanneryWeb.Components.Topbar do | ||||
|     <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400"> | ||||
|       <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|         <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|           <%= live_redirect to: Routes.live_path(Endpoint, HomeLive), | ||||
|             class: "inline mx-2 my-1 leading-5 text-xl text-white" | ||||
|             do %> | ||||
|           <.link | ||||
|             navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|             class="inline mx-2 my-1 leading-5 text-xl text-white" | ||||
|           > | ||||
|             <img | ||||
|               src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|               alt={gettext("Cannery logo")} | ||||
|               class="inline-block h-8 mx-1" | ||||
|             /> | ||||
|             <h1 class="inline hover:underline">Cannery</h1> | ||||
|           <% end %> | ||||
|           </.link> | ||||
|  | ||||
|           <%= if @title_content do %> | ||||
|             <span class="mx-2 my-1"> | ||||
| @@ -41,78 +42,98 @@ defmodule CanneryWeb.Components.Topbar do | ||||
|           text-lg text-white text-ellipsis"> | ||||
|           <%= if @current_user do %> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(gettext("Tags"), | ||||
|                 to: Routes.tag_index_path(Endpoint, :index), | ||||
|                 class: "text-primary-600 text-white hover:underline" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.tag_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Tags") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(gettext("Containers"), | ||||
|                 to: Routes.container_index_path(Endpoint, :index), | ||||
|                 class: "text-primary-600 text-white hover:underline" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.container_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Containers") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(gettext("Catalog"), | ||||
|                 to: Routes.ammo_type_index_path(Endpoint, :index), | ||||
|                 class: "text-primary-600 text-white hover:underline" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Catalog") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(gettext("Ammo"), | ||||
|                 to: Routes.ammo_group_index_path(Endpoint, :index), | ||||
|                 class: "text-primary-600 text-white hover:underline" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Ammo") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(gettext("Range"), | ||||
|                 to: Routes.range_index_path(Endpoint, :index), | ||||
|                 class: "text-primary-600 text-white hover:underline" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.range_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Range") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <%= if @current_user.role == :admin do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <%= live_redirect(gettext("Invites"), | ||||
|                   to: Routes.invite_index_path(Endpoint, :index), | ||||
|                   class: "text-primary-600 text-white hover:underline" | ||||
|                 ) %> | ||||
|                 <.link | ||||
|                   navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|                   class="text-primary-600 text-white hover:underline" | ||||
|                 > | ||||
|                   <%= gettext("Invites") %> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(@current_user.email, | ||||
|                 to: Routes.user_settings_path(Endpoint, :edit), | ||||
|                 class: "text-primary-600 text-white hover:underline truncate" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.user_settings_path(Endpoint, :edit)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= @current_user.email %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= link to: Routes.user_session_path(Endpoint, :delete), | ||||
|                    method: :delete, | ||||
|                    data: [confirm: dgettext("prompts", "Are you sure you want to log out?")] do %> | ||||
|               <.link | ||||
|                 href={Routes.user_session_path(Endpoint, :delete)} | ||||
|                 method="delete" | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||
|               > | ||||
|                 <i class="fas fa-sign-out-alt"></i> | ||||
|               <% end %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <%= live_redirect to: Routes.live_dashboard_path(Endpoint, :home), | ||||
|                   class: "text-primary-600 text-white hover:underline" do %> | ||||
|                 <.link | ||||
|                   navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|                   class="text-primary-600 text-white hover:underline" | ||||
|                 > | ||||
|                   <i class="fas fa-gauge"></i> | ||||
|                 <% end %> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|           <% else %> | ||||
|             <%= if Accounts.allow_registration?() do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <%= live_redirect(dgettext("actions", "Register"), | ||||
|                   to: Routes.user_registration_path(Endpoint, :new), | ||||
|                   class: "text-primary-600 text-white hover:underline truncate" | ||||
|                 ) %> | ||||
|               </li> | ||||
|             <% end %> | ||||
|             <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_registration_path(Endpoint, :new)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Register") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <%= live_redirect(dgettext("actions", "Log in"), | ||||
|                 to: Routes.user_session_path(Endpoint, :new), | ||||
|                 class: "text-primary-600 text-white hover:underline truncate" | ||||
|               ) %> | ||||
|               <.link | ||||
|                 navigate={Routes.user_session_path(Endpoint, :new)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Log in") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|           <% end %> | ||||
|         </ul> | ||||
|   | ||||
| @@ -4,14 +4,18 @@ defmodule CanneryWeb.Components.UserCard do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Accounts.User | ||||
|  | ||||
|   attr :user, User, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def user_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"user-#{@user.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|           border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|           transition-all duration-300 ease-in-out" | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|         <%= @user.email %> | ||||
| @@ -19,24 +23,29 @@ defmodule CanneryWeb.Components.UserCard do | ||||
|  | ||||
|       <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|         <p> | ||||
|           <%= if @user.confirmed_at |> is_nil() do %> | ||||
|             Email unconfirmed | ||||
|           <%= if @user.confirmed_at do %> | ||||
|             <%= gettext( | ||||
|               "User was confirmed at%{confirmed_datetime}", | ||||
|               confirmed_datetime: "" | ||||
|             ) %> | ||||
|             <.datetime datetime={@user.confirmed_at} /> | ||||
|           <% else %> | ||||
|             User was confirmed at <%= @user.confirmed_at |> display_datetime() %> | ||||
|             <%= gettext("Email unconfirmed") %> | ||||
|           <% end %> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <%= gettext("User registered on") %> | ||||
|           <%= @user.inserted_at |> display_datetime() %> | ||||
|           <%= gettext( | ||||
|             "User registered on%{registered_datetime}", | ||||
|             registered_datetime: "" | ||||
|           ) %> | ||||
|           <.datetime datetime={@user.inserted_at} /> | ||||
|         </p> | ||||
|       </h3> | ||||
|  | ||||
|       <%= if @inner_block do %> | ||||
|         <div class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|       <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
							
								
								
									
										69
									
								
								lib/cannery_web/controllers/export_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								lib/cannery_web/controllers/export_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| defmodule CanneryWeb.ExportController do | ||||
|   use CanneryWeb, :controller | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|  | ||||
|   def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do | ||||
|     ammo_types = | ||||
|       Ammo.list_ammo_types(current_user) | ||||
|       |> Enum.map(fn ammo_type -> | ||||
|         average_cost = ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) | ||||
|         round_count = ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) | ||||
|         used_count = ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) | ||||
|         ammo_group_count = ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) | ||||
|  | ||||
|         ammo_type | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "average_cost" => average_cost, | ||||
|           "round_count" => round_count, | ||||
|           "used_count" => used_count, | ||||
|           "ammo_group_count" => ammo_group_count | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     ammo_groups = | ||||
|       Ammo.list_ammo_groups(nil, true, current_user) | ||||
|       |> Enum.map(fn ammo_group -> | ||||
|         cpr = ammo_group |> Ammo.get_cpr() | ||||
|         used_count = ammo_group |> Ammo.get_used_count() | ||||
|         original_count = ammo_group |> Ammo.get_original_count() | ||||
|         percentage_remaining = ammo_group |> Ammo.get_percentage_remaining() | ||||
|  | ||||
|         ammo_group | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "used_count" => used_count, | ||||
|           "percentage_remaining" => percentage_remaining, | ||||
|           "original_count" => original_count, | ||||
|           "cpr" => cpr | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     shot_groups = ActivityLog.list_shot_groups(current_user) | ||||
|  | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) | ||||
|       |> Enum.map(fn container -> | ||||
|         ammo_group_count = container |> Containers.get_container_ammo_group_count!() | ||||
|         round_count = container |> Containers.get_container_rounds!() | ||||
|  | ||||
|         container | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "ammo_group_count" => ammo_group_count, | ||||
|           "round_count" => round_count | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     json(conn, %{ | ||||
|       user: current_user, | ||||
|       ammo_types: ammo_types, | ||||
|       ammo_groups: ammo_groups, | ||||
|       shot_groups: shot_groups, | ||||
|       containers: containers | ||||
|     }) | ||||
|   end | ||||
| end | ||||
| @@ -1,15 +1,12 @@ | ||||
| defmodule CanneryWeb.UserRegistrationController do | ||||
|   use CanneryWeb, :controller | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts, Invites} | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Cannery.{Accounts, Accounts.Invites} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   def new(conn, %{"invite" => invite_token}) do | ||||
|     invite = Invites.get_invite_by_token(invite_token) | ||||
|  | ||||
|     if invite do | ||||
|       conn |> render_new(invite) | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> render_new(invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
| @@ -28,19 +25,17 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|   end | ||||
|  | ||||
|   # renders new user registration page | ||||
|   defp render_new(conn, invite \\ nil) do | ||||
|   defp render_new(conn, invite_token \\ nil) do | ||||
|     render(conn, "new.html", | ||||
|       changeset: Accounts.change_user_registration(%User{}), | ||||
|       invite: invite, | ||||
|       changeset: Accounts.change_user_registration(), | ||||
|       invite_token: invite_token, | ||||
|       page_title: gettext("Register") | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do | ||||
|     invite = Invites.get_invite_by_token(invite_token) | ||||
|  | ||||
|     if invite do | ||||
|       conn |> create_user(attrs, invite) | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> create_user(attrs, invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
| @@ -58,13 +53,9 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp create_user(conn, %{"user" => user_params}, invite \\ nil) do | ||||
|     case Accounts.register_user(user_params) do | ||||
|   defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do | ||||
|     case Accounts.register_user(user_params, invite_token) do | ||||
|       {:ok, user} -> | ||||
|         unless invite |> is_nil() do | ||||
|           invite |> Invites.use_invite!() | ||||
|         end | ||||
|  | ||||
|         Accounts.deliver_user_confirmation_instructions( | ||||
|           user, | ||||
|           &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
| @@ -74,8 +65,13 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|         |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) | ||||
|         |> redirect(to: Routes.user_session_path(Endpoint, :new)) | ||||
|  | ||||
|       {:error, :invalid_token} -> | ||||
|         conn | ||||
|         |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|         |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         conn |> render("new.html", changeset: changeset, invite: invite) | ||||
|         conn |> render("new.html", changeset: changeset, invite_token: invite_token) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -73,14 +73,14 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|          ammo_group_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       case action do | ||||
|         :new -> :insert | ||||
|         :edit -> :update | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> | ||||
|       cond do | ||||
|         action in [:new, :clone] -> | ||||
|           ammo_type = | ||||
|             if ammo_group_params |> Map.has_key?("ammo_type_id"), | ||||
|               do: ammo_group_params |> Map.get("ammo_type_id") |> Ammo.get_ammo_type!(user), | ||||
| @@ -93,8 +93,8 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|  | ||||
|           ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params) | ||||
|  | ||||
|         :edit -> | ||||
|           ammo_group |> AmmoGroup.update_changeset(ammo_group_params) | ||||
|         action == :edit -> | ||||
|           ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
| @@ -116,7 +116,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|       case Ammo.update_ammo_group(ammo_group, ammo_group_params, current_user) do | ||||
|         {:ok, _ammo_group} -> | ||||
|           prompt = dgettext("prompts", "Ammo updated successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
| @@ -127,9 +127,10 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|  | ||||
|   defp save_ammo_group( | ||||
|          %{assigns: %{changeset: changeset}} = socket, | ||||
|          :new, | ||||
|          action, | ||||
|          %{"multiplier" => multiplier_str} = ammo_group_params | ||||
|        ) do | ||||
|        ) | ||||
|        when action in [:new, :clone] do | ||||
|     socket = | ||||
|       case multiplier_str |> Integer.parse() do | ||||
|         {multiplier, _remainder} | ||||
| @@ -181,7 +182,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|             count | ||||
|           ) | ||||
|  | ||||
|         socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|         socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|       {:error, %Changeset{} = changeset} -> | ||||
|         socket |> assign(changeset: changeset) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|   </h2> | ||||
|  | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="ammo_group-form" | ||||
|     phx-target={@myself} | ||||
| @@ -12,11 +12,12 @@ | ||||
|     phx-submit="save" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :ammo_type_id, gettext("Ammo type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= select(f, :ammo_type_id, ammo_type_options(@ammo_types), | ||||
| @@ -38,6 +39,14 @@ | ||||
|     ) %> | ||||
|     <%= error_tag(f, :price_paid, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :purchased_on, gettext("Purchased on"), class: "title text-lg text-primary-600") %> | ||||
|     <%= date_input(f, :purchased_on, | ||||
|       class: "input input-primary col-span-2", | ||||
|       phx_update: "ignore", | ||||
|       value: @changeset |> Changeset.get_field(:purchased_on) || Date.utc_today() | ||||
|     ) %> | ||||
|     <%= error_tag(f, :purchased_on, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
| @@ -52,7 +61,7 @@ | ||||
|     <%= error_tag(f, :container_id, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= case @action do %> | ||||
|       <% :new -> %> | ||||
|       <% action when action in [:new, :clone] -> %> | ||||
|         <hr class="hr col-span-3" /> | ||||
|  | ||||
|         <%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %> | ||||
|   | ||||
| @@ -4,17 +4,20 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Containers, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Containers} | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> display_ammo_groups()} | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, apply_action(socket, live_action, params)} | ||||
|     {:noreply, apply_action(socket, live_action, params) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   defp apply_action( | ||||
| @@ -23,37 +26,67 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|          %{"id" => id} | ||||
|        ) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Record shots")) | ||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Record shots"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :move, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Move Ammo group")) | ||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Move ammo"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Edit Ammo group")) | ||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit ammo"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: dgettext("actions", "Add Ammo"), | ||||
|       ammo_group: %{Ammo.get_ammo_group!(id, current_user) | id: nil} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, dgettext("actions", "Add Ammo")) | ||||
|     |> assign(:ammo_group, %AmmoGroup{}) | ||||
|     |> assign( | ||||
|       page_title: dgettext("actions", "Add Ammo"), | ||||
|       ammo_group: %AmmoGroup{} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket |> assign(:page_title, gettext("Ammo groups")) |> assign(:ammo_group, nil) | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Ammo"), | ||||
|       search: nil, | ||||
|       ammo_group: nil | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Ammo"), | ||||
|       search: search, | ||||
|       ammo_group: nil | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     Ammo.get_ammo_group!(id, current_user) |> Ammo.delete_ammo_group!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "Ammo group deleted succesfully") | ||||
|     prompt = dgettext("prompts", "Ammo deleted succesfully") | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()} | ||||
|   end | ||||
| @@ -72,140 +105,35 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     {:noreply, socket |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_groups(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     ammo_groups = Ammo.list_ammo_groups(current_user) |> Repo.preload([:ammo_type, :container]) | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     socket = | ||||
|       socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :search, search_term)) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_groups( | ||||
|          %{assigns: %{search: search, current_user: current_user, show_used: show_used}} = socket | ||||
|        ) do | ||||
|     ammo_groups = Ammo.list_ammo_groups(search, show_used, current_user) | ||||
|     ammo_types_count = Ammo.get_ammo_types_count!(current_user) | ||||
|     containers_count = Containers.get_containers_count!(current_user) | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Ammo type"), key: "ammo_type"}, | ||||
|       %{label: gettext("Count"), key: "count"}, | ||||
|       %{label: gettext("Price paid"), key: "price_paid"}, | ||||
|       %{label: gettext("% left"), key: "remaining"}, | ||||
|       %{label: gettext("Range"), key: "range"}, | ||||
|       %{label: gettext("Container"), key: "container"}, | ||||
|       %{label: gettext("Added on"), key: "added_on"}, | ||||
|       %{label: nil, key: "actions", sortable: false} | ||||
|     ] | ||||
|  | ||||
|     rows = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn ammo_group -> ammo_group |> get_row_data_for_ammo_group(columns) end) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       ammo_groups: ammo_groups, | ||||
|       ammo_types_count: ammo_types_count, | ||||
|       containers_count: containers_count, | ||||
|       columns: columns, | ||||
|       rows: rows | ||||
|       containers_count: containers_count | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_ammo_group(AmmoGroup.t(), [map()]) :: [map()] | ||||
|   defp get_row_data_for_ammo_group(ammo_group, columns) do | ||||
|     ammo_group = ammo_group |> Repo.preload([:ammo_type, :container]) | ||||
|  | ||||
|     columns | ||||
|     |> Enum.into(%{}, fn %{key: key} -> {key, get_value_for_key(key, ammo_group)} end) | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(String.t(), AmmoGroup.t()) :: any() | ||||
|   defp get_value_for_key("ammo_type", %{ammo_type: ammo_type}) do | ||||
|     {ammo_type.name, | ||||
|      live_patch(ammo_type.name, | ||||
|        to: Routes.ammo_type_show_path(Endpoint, :show, ammo_type), | ||||
|        class: "link" | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key("price_paid", %{price_paid: nil}), do: {"a", nil} | ||||
|  | ||||
|   defp get_value_for_key("price_paid", %{price_paid: price_paid}), | ||||
|     do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2)) | ||||
|  | ||||
|   defp get_value_for_key("added_on", %{inserted_at: inserted_at}) do | ||||
|     assigns = %{inserted_at: inserted_at} | ||||
|  | ||||
|     {inserted_at, | ||||
|      ~H""" | ||||
|      <%= @inserted_at |> display_datetime() %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key("range", %{staged: staged} = ammo_group) do | ||||
|     assigns = %{ammo_group: ammo_group} | ||||
|  | ||||
|     {staged, | ||||
|      ~H""" | ||||
|      <div class="min-w-20 py-2 px-4 h-full flex flex-col justify-center items-center"> | ||||
|        <button | ||||
|          type="button" | ||||
|          class="mx-2 my-1 btn btn-primary" | ||||
|          phx-click="toggle_staged" | ||||
|          phx-value-ammo_group_id={ammo_group.id} | ||||
|        > | ||||
|          <%= if ammo_group.staged, do: gettext("Unstage"), else: gettext("Stage") %> | ||||
|        </button> | ||||
|  | ||||
|        <%= live_patch(dgettext("actions", "Record shots"), | ||||
|          to: Routes.ammo_group_index_path(Endpoint, :add_shot_group, ammo_group), | ||||
|          class: "mx-2 my-1 btn btn-primary" | ||||
|        ) %> | ||||
|      </div> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key("remaining", ammo_group), | ||||
|     do: "#{ammo_group |> Ammo.get_percentage_remaining()}%" | ||||
|  | ||||
|   defp get_value_for_key("actions", ammo_group) do | ||||
|     assigns = %{ammo_group: ammo_group} | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|       <%= live_redirect to: Routes.ammo_group_show_path(Endpoint, :show, ammo_group), | ||||
|                     class: "text-primary-600 link", | ||||
|                     data: [qa: "view-#{ammo_group.id}"] do %> | ||||
|         <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|       <% end %> | ||||
|  | ||||
|       <%= live_patch to: Routes.ammo_group_index_path(Endpoint, :edit, ammo_group), | ||||
|                   class: "text-primary-600 link", | ||||
|                   data: [qa: "edit-#{ammo_group.id}"] do %> | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       <% end %> | ||||
|  | ||||
|       <%= link to: "#", | ||||
|             class: "text-primary-600 link", | ||||
|             phx_click: "delete", | ||||
|             phx_value_id: ammo_group.id, | ||||
|             data: [ | ||||
|               confirm: dgettext("prompts", "Are you sure you want to delete this ammo?"), | ||||
|               qa: "delete-#{ammo_group.id}" | ||||
|             ] do %> | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       <% end %> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key("container", %{container: nil}), do: {nil, nil} | ||||
|  | ||||
|   defp get_value_for_key("container", %{container: %{name: container_name}} = ammo_group) do | ||||
|     assigns = %{ammo_group: ammo_group} | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <div class="min-w-20 py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|        <%= live_patch(@ammo_group.container.name, | ||||
|          to: Routes.ammo_group_index_path(Endpoint, :move, @ammo_group), | ||||
|          class: "btn btn-primary" | ||||
|        ) %> | ||||
|      </div> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(key, ammo_group), | ||||
|     do: ammo_group |> Map.get(key |> String.to_existing_atom()) | ||||
| end | ||||
|   | ||||
| @@ -3,12 +3,13 @@ | ||||
|     <%= gettext("Ammo") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No Ammo") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   <% end %> | ||||
|   <h2 | ||||
|     :if={@ammo_groups |> Enum.empty?() and @search |> is_nil()} | ||||
|     class="title text-xl text-primary-600" | ||||
|   > | ||||
|     <%= gettext("No Ammo") %> | ||||
|     <%= display_emoji("😔") %> | ||||
|   </h2> | ||||
|  | ||||
|   <%= cond do %> | ||||
|     <% @containers_count == 0 -> %> | ||||
| @@ -17,10 +18,9 @@ | ||||
|           <%= dgettext("prompts", "You'll need to") %> | ||||
|         </h2> | ||||
|  | ||||
|         <%= live_patch(dgettext("actions", "add a container first"), | ||||
|           to: Routes.container_index_path(Endpoint, :new), | ||||
|           class: "btn btn-primary" | ||||
|         ) %> | ||||
|         <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "add a container first") %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     <% @ammo_types_count == 0 -> %> | ||||
|       <div class="flex justify-center items-center"> | ||||
| @@ -28,36 +28,141 @@ | ||||
|           <%= dgettext("prompts", "You'll need to") %> | ||||
|         </h2> | ||||
|  | ||||
|         <%= live_patch(dgettext("actions", "add an ammo type first"), | ||||
|           to: Routes.ammo_type_index_path(Endpoint, :new), | ||||
|           class: "btn btn-primary" | ||||
|         ) %> | ||||
|         <.link navigate={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "add an ammo type first") %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     <% @ammo_groups |> Enum.empty?() -> %> | ||||
|       <%= live_patch(dgettext("actions", "Add your first box!"), | ||||
|         to: Routes.ammo_group_index_path(Endpoint, :new), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|     <% @ammo_groups |> Enum.empty?() and @search |> is_nil() -> %> | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add your first box!") %> | ||||
|       </.link> | ||||
|     <% true -> %> | ||||
|       <%= live_patch(dgettext("actions", "Add Ammo"), | ||||
|         to: Routes.ammo_group_index_path(Endpoint, :new), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add Ammo") %> | ||||
|       </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> | ||||
|   <div class="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-xl"> | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={:search} | ||||
|       phx-change="search" | ||||
|       phx-submit="search" | ||||
|       class="grow self-stretch flex flex-col items-stretch" | ||||
|       data-qa="ammo_group_search" | ||||
|     > | ||||
|       <%= text_input(f, :search_term, | ||||
|         class: "input input-primary", | ||||
|         value: @search, | ||||
|         phx_debounce: 300, | ||||
|         placeholder: gettext("Search ammo") | ||||
|       ) %> | ||||
|     </.form> | ||||
|  | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|   </div> | ||||
|  | ||||
|   <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No Ammo") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.Components.TableComponent} | ||||
|       id="ammo_groups_index_table" | ||||
|       action={@live_action} | ||||
|       columns={@columns} | ||||
|       rows={@rows} | ||||
|     /> | ||||
|       module={CanneryWeb.Components.AmmoGroupTableComponent} | ||||
|       id="ammo-group-index-table" | ||||
|       ammo_groups={@ammo_groups} | ||||
|       current_user={@current_user} | ||||
|     > | ||||
|       <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|         <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
|           <%= ammo_type_name %> | ||||
|         </.link> | ||||
|       </:ammo_type> | ||||
|       <:range :let={ammo_group}> | ||||
|         <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> | ||||
|           <button | ||||
|             type="button" | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|             phx-click="toggle_staged" | ||||
|             phx-value-ammo_group_id={ammo_group.id} | ||||
|           > | ||||
|             <%= if ammo_group.staged, do: gettext("Unstage"), else: gettext("Stage") %> | ||||
|           </button> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :add_shot_group, ammo_group)} | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|           > | ||||
|             <%= dgettext("actions", "Record shots") %> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:range> | ||||
|       <:container :let={%{container: %{name: container_name} = container} = ammo_group}> | ||||
|         <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> | ||||
|           <.link | ||||
|             navigate={Routes.container_show_path(Endpoint, :show, container)} | ||||
|             class="mx-2 my-1 link" | ||||
|           > | ||||
|             <%= container_name %> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)} | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|           > | ||||
|             <%= gettext("Move ammo") %> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:container> | ||||
|       <:actions :let={ammo_group}> | ||||
|         <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"view-#{ammo_group.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"edit-#{ammo_group.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"clone-#{ammo_group.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={ammo_group.id} | ||||
|             data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} | ||||
|             data-qa={"delete-#{ammo_group.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:actions> | ||||
|     </.live_component> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= cond do %> | ||||
|   <% @live_action in [:new, :edit] -> %> | ||||
|   <% @live_action in [:new, :edit, :clone] -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.AmmoGroupLive.FormComponent} | ||||
|   | ||||
| @@ -30,7 +30,12 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, socket |> assign(page_title: page_title(live_action)) |> display_ammo_group(id)} | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(page_title: page_title(live_action)) | ||||
|       |> display_ammo_group(id) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp page_title(:add_shot_group), do: gettext("Record Shots") | ||||
| @@ -50,7 +55,7 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|     prompt = dgettext("prompts", "Ammo deleted succesfully") | ||||
|     redirect_to = Routes.ammo_group_index_path(socket, :index) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_redirect(to: redirect_to)} | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -69,14 +74,14 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|   def handle_event( | ||||
|         "delete_shot_group", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket | ||||
|         %{assigns: %{ammo_group: %{id: ammo_group_id}, current_user: current_user}} = socket | ||||
|       ) do | ||||
|     {:ok, _} = | ||||
|       ActivityLog.get_shot_group!(id, current_user) | ||||
|       |> ActivityLog.delete_shot_group(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "Shot records deleted succesfully") | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_group(ammo_group)} | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_group(ammo_group_id)} | ||||
|   end | ||||
|  | ||||
|   @spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t() | ||||
| @@ -84,10 +89,10 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|     ammo_group = ammo_group |> Repo.preload([:container, :ammo_type, :shot_groups], force: true) | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Rounds shot"), key: "count"}, | ||||
|       %{label: gettext("Notes"), key: "notes"}, | ||||
|       %{label: gettext("Date"), key: "date"}, | ||||
|       %{label: nil, key: "actions", sortable: false} | ||||
|       %{label: gettext("Rounds shot"), key: :count}, | ||||
|       %{label: gettext("Notes"), key: :notes}, | ||||
|       %{label: gettext("Date"), key: :date}, | ||||
|       %{label: nil, key: :actions, sortable: false} | ||||
|     ] | ||||
|  | ||||
|     rows = | ||||
| @@ -102,41 +107,48 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|   defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id), | ||||
|     do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user)) | ||||
|  | ||||
|   @spec get_table_row_for_shot_group(AmmoGroup.t(), ShotGroup.t(), [map()]) :: [map()] | ||||
|   @spec get_table_row_for_shot_group(AmmoGroup.t(), ShotGroup.t(), [map()]) :: map() | ||||
|   defp get_table_row_for_shot_group(ammo_group, %{date: date} = shot_group, columns) do | ||||
|     assigns = %{ammo_group: ammo_group, shot_group: shot_group} | ||||
|  | ||||
|     columns | ||||
|     |> Enum.into(%{}, fn %{key: key} -> | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       value = | ||||
|         case key do | ||||
|           "date" -> | ||||
|             {date, date |> display_date()} | ||||
|           :date -> | ||||
|             assigns = %{date: date} | ||||
|  | ||||
|           "actions" -> | ||||
|             {date, | ||||
|              ~H""" | ||||
|              <.date date={@date} /> | ||||
|              """} | ||||
|  | ||||
|           :actions -> | ||||
|             ~H""" | ||||
|             <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|               <%= live_patch to: Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, shot_group), | ||||
|                           class: "text-primary-600 link", | ||||
|                           data: [qa: "edit-#{shot_group.id}"] do %> | ||||
|               <.link | ||||
|                 patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)} | ||||
|                 class="text-primary-600 link" | ||||
|                 data-qa={"edit-#{@shot_group.id}"} | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|               <% end %> | ||||
|               </.link> | ||||
|  | ||||
|               <%= link to: "#", | ||||
|                     class: "text-primary-600 link", | ||||
|                     phx_click: "delete_shot_group", | ||||
|                     phx_value_id: shot_group.id, | ||||
|                     data: [ | ||||
|                       confirm: dgettext("prompts", "Are you sure you want to delete this shot record?"), | ||||
|                       qa: "delete-#{shot_group.id}" | ||||
|                     ] do %> | ||||
|               <.link | ||||
|                 href="#" | ||||
|                 class="text-primary-600 link" | ||||
|                 phx-click="delete_shot_group" | ||||
|                 phx-value-id={@shot_group.id} | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")} | ||||
|                 data-qa={"delete-#{@shot_group.id}"} | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|               <% end %> | ||||
|               </.link> | ||||
|             </div> | ||||
|             """ | ||||
|  | ||||
|           key -> | ||||
|             shot_group |> Map.get(key |> String.to_existing_atom()) | ||||
|             shot_group |> Map.get(key) | ||||
|         end | ||||
|  | ||||
|       {key, value} | ||||
|   | ||||
| @@ -11,12 +11,12 @@ | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Original count:") %> | ||||
|       <%= @ammo_group.count + Ammo.get_used_count(@ammo_group) %> | ||||
|       <%= Ammo.get_original_count(@ammo_group) %> | ||||
|     </span> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Percentage left:") %> | ||||
|       <%= "#{@ammo_group |> Ammo.get_percentage_remaining()}%" %> | ||||
|       <%= gettext("%{percentage}%", percentage: @ammo_group |> Ammo.get_percentage_remaining()) %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @ammo_group.notes do %> | ||||
| @@ -27,8 +27,8 @@ | ||||
|     <% end %> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Added on:") %> | ||||
|       <%= @ammo_group.inserted_at |> display_datetime() %> | ||||
|       <%= gettext("Purchased on:") %> | ||||
|       <.date date={@ammo_group.purchased_on} /> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @ammo_group.price_paid do %> | ||||
| @@ -52,46 +52,54 @@ | ||||
|  | ||||
|   <div class="flex flex-col justify-center items-center"> | ||||
|     <div class="flex flex-wrap justify-center items-center text-primary-600"> | ||||
|       <%= live_patch(dgettext("actions", "View in Catalog"), | ||||
|         to: Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type), | ||||
|         class: "mx-4 my-2 btn btn-primary", | ||||
|         data: [qa: "details"] | ||||
|       ) %> | ||||
|       <.link | ||||
|         navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type)} | ||||
|         class="mx-4 my-2 btn btn-primary" | ||||
|         data-qa="details" | ||||
|       > | ||||
|         <%= dgettext("actions", "View in Catalog") %> | ||||
|       </.link> | ||||
|  | ||||
|       <%= live_patch to: Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group), | ||||
|                  class: "mx-4 my-2 text-primary-600 link", | ||||
|                  data: [qa: "edit"] do %> | ||||
|       <.link | ||||
|         patch={Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group)} | ||||
|         class="mx-4 my-2 text-primary-600 link" | ||||
|         data-qa="edit" | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       <% end %> | ||||
|       </.link> | ||||
|  | ||||
|       <%= link to: "#", | ||||
|            class: "mx-4 my-2 text-primary-600 link", | ||||
|            phx_click: "delete", | ||||
|            data: [ | ||||
|              confirm: dgettext("prompts", "Are you sure you want to delete this ammo?"), | ||||
|              qa: "delete" | ||||
|            ] do %> | ||||
|       <.link | ||||
|         href="#" | ||||
|         class="mx-4 my-2 text-primary-600 link" | ||||
|         phx-click="delete" | ||||
|         data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} | ||||
|         data-qa="delete" | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       <% end %> | ||||
|       </.link> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex flex-wrap justify-center items-center text-primary-600"> | ||||
|       <button type="button" class="mx-4 my-2 btn btn-primary" phx-click="toggle_staged"> | ||||
|         <%= if @ammo_group.staged, | ||||
|           do: gettext("Unstage from range"), | ||||
|           else: gettext("Stage for range") %> | ||||
|           do: dgettext("actions", "Unstage from range"), | ||||
|           else: dgettext("actions", "Stage for range") %> | ||||
|       </button> | ||||
|  | ||||
|       <%= live_patch(dgettext("actions", "Move containers"), | ||||
|         to: Routes.ammo_group_show_path(Endpoint, :move, @ammo_group), | ||||
|         class: "btn btn-primary", | ||||
|         data: [qa: "move"] | ||||
|       ) %> | ||||
|       <.link | ||||
|         patch={Routes.ammo_group_show_path(Endpoint, :move, @ammo_group)} | ||||
|         class="btn btn-primary" | ||||
|         data-qa="move" | ||||
|       > | ||||
|         <%= dgettext("actions", "Move containers") %> | ||||
|       </.link> | ||||
|  | ||||
|       <%= live_patch(dgettext("actions", "Record shots"), | ||||
|         to: Routes.ammo_group_show_path(Endpoint, :add_shot_group, @ammo_group), | ||||
|         class: "mx-4 my-2 btn btn-primary" | ||||
|       ) %> | ||||
|       <.link | ||||
|         patch={Routes.ammo_group_show_path(Endpoint, :add_shot_group, @ammo_group)} | ||||
|         class="mx-4 my-2 btn btn-primary" | ||||
|       > | ||||
|         <%= dgettext("actions", "Record shots") %> | ||||
|       </.link> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   | ||||
| @@ -35,15 +35,15 @@ defmodule CanneryWeb.AmmoTypeLive.FormComponent do | ||||
|          ammo_type_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       case action do | ||||
|         :new -> :insert | ||||
|         :edit -> :update | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> ammo_type |> AmmoType.create_changeset(user, ammo_type_params) | ||||
|         :edit -> ammo_type |> AmmoType.update_changeset(ammo_type_params) | ||||
|       cond do | ||||
|         action in [:new, :clone] -> ammo_type |> AmmoType.create_changeset(user, ammo_type_params) | ||||
|         action == :edit -> ammo_type |> AmmoType.update_changeset(ammo_type_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
| @@ -65,7 +65,7 @@ defmodule CanneryWeb.AmmoTypeLive.FormComponent do | ||||
|       case Ammo.update_ammo_type(ammo_type, ammo_type_params, current_user) do | ||||
|         {:ok, %{name: ammo_type_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: ammo_type_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
| @@ -76,14 +76,15 @@ defmodule CanneryWeb.AmmoTypeLive.FormComponent do | ||||
|  | ||||
|   defp save_ammo_type( | ||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, | ||||
|          :new, | ||||
|          action, | ||||
|          ammo_type_params | ||||
|        ) do | ||||
|        ) | ||||
|        when action in [:new, :clone] do | ||||
|     socket = | ||||
|       case Ammo.create_ammo_type(ammo_type_params, current_user) do | ||||
|         {:ok, %{name: ammo_type_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: ammo_type_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="ammo_type-form" | ||||
|     phx-target={@myself} | ||||
| @@ -11,11 +11,12 @@ | ||||
|     phx-submit="save" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %> | ||||
|   | ||||
| @@ -4,13 +4,15 @@ defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|  | ||||
|   alias Cannery.{Ammo, Ammo.AmmoType} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> list_ammo_types()} | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -19,19 +21,49 @@ defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     %{name: ammo_type_name} = ammo_type = Ammo.get_ammo_type!(id, current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Edit Ammo type")) | ||||
|     |> assign(:ammo_type, Ammo.get_ammo_type!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name), | ||||
|       ammo_type: ammo_type | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("New Ammo type"), | ||||
|       ammo_type: %{Ammo.get_ammo_type!(id, current_user) | id: nil} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("New Ammo type")) | ||||
|     |> assign(:ammo_type, %AmmoType{}) | ||||
|     |> assign( | ||||
|       page_title: gettext("New Ammo type"), | ||||
|       ammo_type: %AmmoType{} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket |> assign(:page_title, gettext("Ammo types")) |> assign(:ammo_type, nil) | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Catalog"), | ||||
|       search: nil, | ||||
|       ammo_type: nil | ||||
|     ) | ||||
|     |> list_ammo_types() | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Catalog"), | ||||
|       search: search, | ||||
|       ammo_type: nil | ||||
|     ) | ||||
|     |> list_ammo_types() | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -43,111 +75,22 @@ defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_types(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     ammo_types = Ammo.list_ammo_types(current_user) | ||||
|  | ||||
|     columns = | ||||
|       [ | ||||
|         %{label: gettext("Name"), key: "name", type: :string}, | ||||
|         %{label: gettext("Bullet type"), key: "bullet_type", type: :string}, | ||||
|         %{label: gettext("Bullet core"), key: "bullet_core", type: :string}, | ||||
|         %{label: gettext("Cartridge"), key: "cartridge", type: :string}, | ||||
|         %{label: gettext("Caliber"), key: "caliber", type: :string}, | ||||
|         %{label: gettext("Case material"), key: "case_material", type: :string}, | ||||
|         %{label: gettext("Jacket type"), key: "jacket_type", type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: "muzzle_velocity", type: :string}, | ||||
|         %{label: gettext("Powder type"), key: "powder_type", type: :string}, | ||||
|         %{ | ||||
|           label: gettext("Powder grains per charge"), | ||||
|           key: "powder_grains_per_charge", | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Grains"), key: "grains", type: :string}, | ||||
|         %{label: gettext("Pressure"), key: "pressure", type: :string}, | ||||
|         %{label: gettext("Primer type"), key: "primer_type", type: :string}, | ||||
|         %{label: gettext("Firing type"), key: "firing_type", type: :string}, | ||||
|         %{label: gettext("Tracer"), key: "tracer", type: :boolean}, | ||||
|         %{label: gettext("Incendiary"), key: "incendiary", type: :boolean}, | ||||
|         %{label: gettext("Blank"), key: "blank", type: :boolean}, | ||||
|         %{label: gettext("Corrosive"), key: "corrosive", type: :boolean}, | ||||
|         %{label: gettext("Manufacturer"), key: "manufacturer", type: :string}, | ||||
|         %{label: gettext("UPC"), key: "upc", type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = if type == :boolean, do: false, else: nil | ||||
|  | ||||
|         ammo_types | ||||
|         |> Enum.any?(fn ammo_type -> | ||||
|           not (ammo_type |> Map.get(key |> String.to_existing_atom()) == default_value) | ||||
|         end) | ||||
|       end) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Total # of rounds"), key: "round_count", type: :round_count}, | ||||
|         %{label: gettext("Average Price paid"), key: "avg_price_paid", type: :avg_price_paid}, | ||||
|         %{label: nil, key: "actions", type: :actions, sortable: false} | ||||
|       ]) | ||||
|  | ||||
|     rows = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn ammo_type -> ammo_type |> get_ammo_type_values(columns, current_user) end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_values(ammo_type, columns, current_user) do | ||||
|     columns | ||||
|     |> Enum.into(%{}, fn %{key: key, type: type} -> | ||||
|       {key, get_ammo_type_value(type, key, ammo_type, current_user)} | ||||
|     end) | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:boolean, key, ammo_type, _current_user), | ||||
|     do: ammo_type |> Map.get(key |> String.to_existing_atom()) |> humanize() | ||||
|  | ||||
|   defp get_ammo_type_value(:round_count, _key, ammo_type, current_user), | ||||
|     do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) | ||||
|  | ||||
|   defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, current_user) do | ||||
|     case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do | ||||
|       nil -> gettext("No cost information") | ||||
|       count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2)) | ||||
|     end | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, | ||||
|      socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :search, search_term))} | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:actions, _key, ammo_type, _current_user) do | ||||
|     assigns = %{ammo_type: ammo_type} | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|       <%= live_redirect to: Routes.ammo_type_show_path(Endpoint, :show, ammo_type), | ||||
|                     class: "text-primary-600 link", | ||||
|                     data: [qa: "view-#{ammo_type.id}"] do %> | ||||
|         <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|       <% end %> | ||||
|  | ||||
|       <%= live_patch to: Routes.ammo_type_index_path(Endpoint, :edit, ammo_type), | ||||
|                   class: "text-primary-600 link", | ||||
|                   data: [qa: "edit-#{ammo_type.id}"] do %> | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       <% end %> | ||||
|  | ||||
|       <%= link to: "#", | ||||
|             class: "text-primary-600 link", | ||||
|             phx_click: "delete", | ||||
|             phx_value_id: ammo_type.id, | ||||
|             data: [ | ||||
|               confirm: dgettext("prompts", "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", name: ammo_type.name), | ||||
|               qa: "delete-#{ammo_type.id}" | ||||
|             ] do %> | ||||
|         <i class="fa-lg fas fa-trash"></i> | ||||
|       <% end %> | ||||
|     </div> | ||||
|     """ | ||||
|   defp list_ammo_types(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     socket |> assign(ammo_types: Ammo.list_ammo_types(search, current_user)) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(nil, _key, _ammo_type, _current_user), do: nil | ||||
|  | ||||
|   defp get_ammo_type_value(_other, key, ammo_type, _current_user), | ||||
|     do: ammo_type |> Map.get(key |> String.to_existing_atom()) | ||||
| end | ||||
|   | ||||
| @@ -3,43 +3,119 @@ | ||||
|     <%= gettext("Catalog") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @rows |> Enum.empty?() do %> | ||||
|   <%= if @ammo_types |> Enum.empty?() and @search |> is_nil() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No Ammo Types") %> | ||||
|       <%= gettext("No Ammo types") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= live_patch(dgettext("actions", "Add your first type!"), | ||||
|       to: Routes.ammo_type_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Add your first type!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <%= live_patch(dgettext("actions", "New Ammo type"), | ||||
|       to: Routes.ammo_type_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "New Ammo type") %> | ||||
|     </.link> | ||||
|  | ||||
|     <.live_component | ||||
|       module={CanneryWeb.Components.TableComponent} | ||||
|       id="ammo_types_index_table" | ||||
|       action={@live_action} | ||||
|       columns={@columns} | ||||
|       rows={@rows} | ||||
|     /> | ||||
|     <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-xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="ammo_type_search" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           value: @search, | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search catalog") | ||||
|         ) %> | ||||
|       </.form> | ||||
|  | ||||
|       <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|         <span class="title text-lg text-primary-600"> | ||||
|           <%= gettext("Show used") %> | ||||
|         </span> | ||||
|       </.toggle_button> | ||||
|     </div> | ||||
|  | ||||
|     <%= if @ammo_types |> Enum.empty?() do %> | ||||
|       <h2 class="title text-xl text-primary-600"> | ||||
|         <%= gettext("No Ammo types") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|     <% else %> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.AmmoTypeTableComponent} | ||||
|         id="ammo_types_index_table" | ||||
|         action={@live_action} | ||||
|         ammo_types={@ammo_types} | ||||
|         current_user={@current_user} | ||||
|         show_used={@show_used} | ||||
|       > | ||||
|         <:actions :let={ammo_type}> | ||||
|           <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|             <.link | ||||
|               navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"view-#{ammo_type.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"edit-#{ammo_type.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"clone-#{ammo_type.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               href="#" | ||||
|               class="text-primary-600 link" | ||||
|               phx-click="delete" | ||||
|               phx-value-id={ammo_type.id} | ||||
|               data-confirm={ | ||||
|                 dgettext( | ||||
|                   "prompts", | ||||
|                   "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", | ||||
|                   name: ammo_type.name | ||||
|                 ) | ||||
|               } | ||||
|               data-qa={"delete-#{ammo_type.id}"} | ||||
|             > | ||||
|               <i class="fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
|           </div> | ||||
|         </:actions> | ||||
|       </.live_component> | ||||
|     <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:new, :edit] do %> | ||||
|   <.modal return_to={Routes.ammo_type_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.AmmoTypeLive.FormComponent} | ||||
|       id={@ammo_type.id || :new} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       ammo_type={@ammo_type} | ||||
|       return_to={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|       } | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action in [:new, :edit, :clone]} | ||||
|   return_to={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.AmmoTypeLive.FormComponent} | ||||
|     id={@ammo_type.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     ammo_type={@ammo_type} | ||||
|     return_to={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|     } | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -5,24 +5,41 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.AmmoGroupCard | ||||
|   alias Cannery.Ammo | ||||
|   alias Cannery.{Ammo, Ammo.AmmoType} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), do: {:ok, socket} | ||||
|   @fields_list [ | ||||
|     %{label: gettext("Bullet type:"), key: :bullet_type, type: :string}, | ||||
|     %{label: gettext("Bullet core:"), key: :bullet_core, type: :string}, | ||||
|     %{label: gettext("Cartridge:"), key: :cartridge, type: :string}, | ||||
|     %{label: gettext("Caliber:"), key: :caliber, type: :string}, | ||||
|     %{label: gettext("Case material:"), key: :case_material, type: :string}, | ||||
|     %{label: gettext("Jacket type:"), key: :jacket_type, type: :string}, | ||||
|     %{label: gettext("Muzzle velocity:"), key: :muzzle_velocity, type: :string}, | ||||
|     %{label: gettext("Powder type:"), key: :powder_type, type: :string}, | ||||
|     %{label: gettext("Powder grains per charge:"), key: :powder_grains_per_charge, type: :string}, | ||||
|     %{label: gettext("Grains:"), key: :grains, type: :string}, | ||||
|     %{label: gettext("Pressure:"), key: :pressure, type: :string}, | ||||
|     %{label: gettext("Primer type:"), key: :primer_type, type: :string}, | ||||
|     %{label: gettext("Firing type:"), key: :firing_type, type: :string}, | ||||
|     %{label: gettext("Tracer:"), key: :tracer, type: :boolean}, | ||||
|     %{label: gettext("Incendiary:"), key: :incendiary, type: :boolean}, | ||||
|     %{label: gettext("Blank:"), key: :blank, type: :boolean}, | ||||
|     %{label: gettext("Corrosive:"), key: :corrosive, type: :boolean}, | ||||
|     %{label: gettext("Manufacturer:"), key: :manufacturer, type: :string}, | ||||
|     %{label: gettext("UPC:"), key: :upc, type: :string} | ||||
|   ] | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _params, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     ammo_type = Ammo.get_ammo_type!(id, current_user) | ||||
|   def mount(_params, _session, %{assigns: %{live_action: live_action}} = socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: live_action == :table)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _params, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign( | ||||
|         page_title: page_title(socket.assigns.live_action), | ||||
|         ammo_type: ammo_type, | ||||
|         ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user), | ||||
|         avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) | ||||
|       ) | ||||
|       |> assign(view_table: live_action == :table) | ||||
|       |> display_ammo_type(id) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
| @@ -38,9 +55,67 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: ammo_type_name) | ||||
|     redirect_to = Routes.ammo_type_index_path(socket, :index) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_redirect(to: redirect_to)} | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} | ||||
|   end | ||||
|  | ||||
|   defp page_title(:show), do: gettext("Show Ammo type") | ||||
|   defp page_title(:edit), do: gettext("Edit Ammo type") | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_table", | ||||
|         _params, | ||||
|         %{assigns: %{view_table: view_table, ammo_type: ammo_type}} = socket | ||||
|       ) do | ||||
|     new_path = | ||||
|       if view_table, | ||||
|         do: Routes.ammo_type_show_path(Endpoint, :show, ammo_type), | ||||
|         else: Routes.ammo_type_show_path(Endpoint, :table, ammo_type) | ||||
|  | ||||
|     {:noreply, socket |> push_patch(to: new_path)} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type( | ||||
|          %{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} = | ||||
|            socket, | ||||
|          %AmmoType{} = ammo_type | ||||
|        ) do | ||||
|     fields_to_display = | ||||
|       @fields_list | ||||
|       |> Enum.any?(fn %{key: field, type: type} -> | ||||
|         default_value = | ||||
|           case type do | ||||
|             :boolean -> false | ||||
|             _other_type -> nil | ||||
|           end | ||||
|  | ||||
|         ammo_type |> Map.get(field) != default_value | ||||
|       end) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: page_title(live_action, ammo_type), | ||||
|       ammo_type: ammo_type, | ||||
|       ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used), | ||||
|       avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user), | ||||
|       fields_list: @fields_list, | ||||
|       fields_to_display: fields_to_display | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type(%{assigns: %{current_user: current_user}} = socket, ammo_type_id) do | ||||
|     socket |> display_ammo_type(Ammo.get_ammo_type!(ammo_type_id, current_user)) | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type(%{assigns: %{ammo_type: ammo_type}} = socket) do | ||||
|     socket |> display_ammo_type(ammo_type) | ||||
|   end | ||||
|  | ||||
|   defp page_title(action, %{name: ammo_type_name}) when action in [:show, :table], | ||||
|     do: ammo_type_name | ||||
|  | ||||
|   defp page_title(:edit, %{name: ammo_type_name}), | ||||
|     do: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name) | ||||
| end | ||||
|   | ||||
| @@ -1,78 +1,71 @@ | ||||
| <div class="mx-auto px-4 sm:px-8 space-y-4 max-w-3xl | ||||
|   flex flex-col justify-center items-center"> | ||||
| <div class="space-y-4 flex flex-col justify-center items-center"> | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= @ammo_type.name %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @ammo_type.desc do %> | ||||
|     <span class="max-w-2xl w-full px-8 py-4 rounded-lg | ||||
|       text-center title text-lg | ||||
|       border border-primary-600"> | ||||
|       <%= @ammo_type.desc %> | ||||
|     </span> | ||||
|   <% end %> | ||||
|   <span | ||||
|     :if={@ammo_type.desc} | ||||
|     class="max-w-2xl w-full px-8 py-4 rounded-lg | ||||
|     text-center title text-lg | ||||
|     border border-primary-600" | ||||
|   > | ||||
|     <%= @ammo_type.desc %> | ||||
|   </span> | ||||
|  | ||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> | ||||
|     <%= live_patch to: Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type), | ||||
|                class: "text-primary-600 link", | ||||
|                data: [qa: "edit"] do %> | ||||
|     <.link | ||||
|       patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)} | ||||
|       class="text-primary-600 link" | ||||
|       data-qa="edit" | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|     <% end %> | ||||
|     </.link> | ||||
|  | ||||
|     <%= link to: "#", | ||||
|          class: "text-primary-600 link", | ||||
|          phx_click: "delete", | ||||
|          data: [ | ||||
|            confirm: | ||||
|              dgettext("prompts", "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", name: @ammo_type.name), | ||||
|            qa: "delete" | ||||
|          ] do %> | ||||
|     <.link | ||||
|       href="#" | ||||
|       class="text-primary-600 link" | ||||
|       phx-click="delete" | ||||
|       data-confirm={ | ||||
|         dgettext( | ||||
|           "prompts", | ||||
|           "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", | ||||
|           name: @ammo_type.name | ||||
|         ) | ||||
|       } | ||||
|       data-qa="delete" | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|     <% end %> | ||||
|     </.link> | ||||
|   </div> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <%= for {field_name, field, type} <- [ | ||||
|           {gettext("Bullet type"), :bullet_type, :string}, | ||||
|           {gettext("Bullet core"), :bullet_core, :string}, | ||||
|           {gettext("Cartridge"), :cartridge, :string}, | ||||
|           {gettext("Caliber"), :caliber, :string}, | ||||
|           {gettext("Case material"), :case_material, :string}, | ||||
|           {gettext("Jacket type"), :jacket_type, :string}, | ||||
|           {gettext("Muzzle velocity"), :muzzle_velocity, :string}, | ||||
|           {gettext("Powder type"), :powder_type, :string}, | ||||
|           {gettext("Powder grains per charge"), :powder_grains_per_charge, :string}, | ||||
|           {gettext("Grains"), :grains, :string}, | ||||
|           {gettext("Pressure"), :pressure, :string}, | ||||
|           {gettext("Primer type"), :primer_type, :string}, | ||||
|           {gettext("Firing type"), :firing_type, :string}, | ||||
|           {gettext("Tracer"), :tracer, :boolean}, | ||||
|           {gettext("Incendiary"), :incendiary, :boolean}, | ||||
|           {gettext("Blank"), :blank, :boolean}, | ||||
|           {gettext("Corrosive"), :corrosive, :boolean}, | ||||
|           {gettext("Manufacturer"), :manufacturer, :string}, | ||||
|           {gettext("UPC"), :upc, :string} | ||||
|         ] do %> | ||||
|       <%= if @ammo_type |> Map.get(field) do %> | ||||
|         <h3 class="title text-lg"> | ||||
|           <%= field_name %>: | ||||
|         </h3> | ||||
|   <%= if @fields_to_display do %> | ||||
|     <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|       <%= for %{label: label, key: key, type: type} <- @fields_list do %> | ||||
|         <%= if @ammo_type |> Map.get(key) do %> | ||||
|           <h3 class="title text-lg"> | ||||
|             <%= label %> | ||||
|           </h3> | ||||
|  | ||||
|         <span class="text-primary-600"> | ||||
|           <%= case type do %> | ||||
|             <% :boolean -> %> | ||||
|               <%= @ammo_type |> Map.get(field) |> humanize() %> | ||||
|             <% _ -> %> | ||||
|               <%= @ammo_type |> Map.get(field) %> | ||||
|           <% end %> | ||||
|         </span> | ||||
|           <span class="text-primary-600"> | ||||
|             <%= case type do %> | ||||
|               <% :boolean -> %> | ||||
|                 <%= @ammo_type |> Map.get(key) |> humanize() %> | ||||
|               <% _ -> %> | ||||
|                 <%= @ammo_type |> Map.get(key) %> | ||||
|             <% end %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|     </div> | ||||
|  | ||||
|     <hr class="hr" /> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Current # of rounds:") %> | ||||
|       <%= gettext("Rounds:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
| @@ -80,24 +73,64 @@ | ||||
|     </span> | ||||
|  | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Total rounds shot:") %> | ||||
|       <%= gettext("Used rounds:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_used_count_for_ammo_type(@current_user) %> | ||||
|     </span> | ||||
|  | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Total ever rounds:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_historical_count_for_ammo_type(@current_user) %> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Packs:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user) %> | ||||
|     </span> | ||||
|  | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Used packs:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_used_ammo_groups_count_for_type(@current_user) %> | ||||
|     </span> | ||||
|  | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Total ever packs:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user, true) %> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Added on:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type.inserted_at |> display_datetime() %> | ||||
|       <.datetime datetime={@ammo_type.inserted_at} /> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @avg_cost_per_round do %> | ||||
|       <h3 class="title text-lg"> | ||||
|         <%= gettext("Average Price paid") %>: | ||||
|         <%= gettext("Average CPR") %>: | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
| @@ -114,32 +147,67 @@ | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div> | ||||
|   <div class="flex justify-center items-center space-x-4"> | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|  | ||||
|     <.toggle_button action="toggle_table" value={@view_table}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("View as table") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|   </div> | ||||
|  | ||||
|   <div class="w-full p-4"> | ||||
|     <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|       <h2 class="mx-8 my-4 title text-lg text-primary-600"> | ||||
|       <h2 class="px-4 title text-lg text-primary-600"> | ||||
|         <%= gettext("No ammo for this type") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|     <% else %> | ||||
|       <div class="flex flex-wrap justify-center items-center"> | ||||
|         <%= for ammo_group <- @ammo_groups do %> | ||||
|           <.ammo_group_card ammo_group={ammo_group} /> | ||||
|         <% end %> | ||||
|       </div> | ||||
|       <%= if @view_table do %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.AmmoGroupTableComponent} | ||||
|           id="ammo-type-show-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           current_user={@current_user} | ||||
|         > | ||||
|           <:container :let={%{container: %{name: container_name} = container}}> | ||||
|             <.link | ||||
|               navigate={Routes.container_show_path(Endpoint, :show, container)} | ||||
|               class="mx-2 my-1 link" | ||||
|             > | ||||
|               <%= container_name %> | ||||
|             </.link> | ||||
|           </:container> | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="flex flex-wrap justify-center items-stretch"> | ||||
|           <.ammo_group_card | ||||
|             :for={ammo_group <- @ammo_groups} | ||||
|             ammo_group={ammo_group} | ||||
|             show_container={true} | ||||
|           /> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:edit] do %> | ||||
|   <.modal return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.AmmoTypeLive.FormComponent} | ||||
|       id={@ammo_type.id} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       ammo_type={@ammo_type} | ||||
|       return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action == :edit} | ||||
|   return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.AmmoTypeLive.FormComponent} | ||||
|     id={@ammo_type.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     ammo_type={@ammo_type} | ||||
|     return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -4,40 +4,38 @@ | ||||
|   </h2> | ||||
|  | ||||
|   <div class="flex flex-wrap justify-center items-center"> | ||||
|     <%= for tag <- @container.tags do %> | ||||
|       <%= link to: "#", | ||||
|             class: "mx-2 my-1 px-4 py-2 rounded-lg title text-xl", | ||||
|             style: "color: #{tag.text_color}; background-color: #{tag.bg_color}", | ||||
|             phx_click: "delete", | ||||
|             phx_value_tag_id: tag.id, | ||||
|             phx_target: @myself, | ||||
|             data: [ | ||||
|               confirm: | ||||
|                 dgettext( | ||||
|                   "prompts", | ||||
|                   "Are you sure you want to remove the %{tag_name} tag from %{container_name}?", | ||||
|                   tag_name: tag.name, | ||||
|                   container_name: @container.name | ||||
|                 ) | ||||
|             ] do %> | ||||
|         <%= tag.name %> | ||||
|         <i class="fa-fw fa-sm fas fa-trash"></i> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|     <.link | ||||
|       :for={tag <- @container.tags} | ||||
|       href="#" | ||||
|       class="mx-2 my-1 px-4 py-2 rounded-lg title text-xl" | ||||
|       style={"color: #{tag.text_color}; background-color: #{tag.bg_color}"} | ||||
|       phx-click="delete" | ||||
|       phx-value-tag-id={tag.id} | ||||
|       phx-target={@myself} | ||||
|       data-confirm={ | ||||
|         dgettext( | ||||
|           "prompts", | ||||
|           "Are you sure you want to remove the %{tag_name} tag from %{container_name}?", | ||||
|           tag_name: tag.name, | ||||
|           container_name: @container.name | ||||
|         ) | ||||
|       } | ||||
|     > | ||||
|       <%= tag.name %> | ||||
|       <i class="fa-fw fa-sm fas fa-trash"></i> | ||||
|     </.link> | ||||
|  | ||||
|     <%= if @container.tags |> Enum.empty?() do %> | ||||
|       <h2 class="title text-xl text-primary-600"> | ||||
|         <%= gettext("No tags") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|     <% end %> | ||||
|     <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No tags") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   </div> | ||||
|  | ||||
|   <%= unless tag_options(@tags, @container) |> Enum.empty?() do %> | ||||
|     <hr class="hr" /> | ||||
|  | ||||
|     <.form | ||||
|       let={f} | ||||
|       :let={f} | ||||
|       for={:tag} | ||||
|       id="add-tag-to-container-form" | ||||
|       class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   | ||||
| @@ -35,15 +35,18 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | ||||
|          container_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       case action do | ||||
|         :new -> :insert | ||||
|         :edit -> :update | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> container |> Container.create_changeset(user, container_params) | ||||
|         :edit -> container |> Container.update_changeset(container_params) | ||||
|       cond do | ||||
|         action in [:new, :clone] -> | ||||
|           container |> Container.create_changeset(user, container_params) | ||||
|  | ||||
|         action == :edit -> | ||||
|           container |> Container.update_changeset(container_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
| @@ -65,7 +68,7 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | ||||
|       case Containers.update_container(container, current_user, container_params) do | ||||
|         {:ok, %{name: container_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: container_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
| @@ -76,14 +79,15 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | ||||
|  | ||||
|   defp save_container( | ||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, | ||||
|          :new, | ||||
|          action, | ||||
|          container_params | ||||
|        ) do | ||||
|        ) | ||||
|        when action in [:new, :clone] do | ||||
|     socket = | ||||
|       case Containers.create_container(container_params, current_user) do | ||||
|         {:ok, %{name: container_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: container_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="container-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
| @@ -11,11 +11,12 @@ | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, | ||||
|   | ||||
| @@ -6,11 +6,16 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.ContainerCard | ||||
|   alias Cannery.{Containers, Containers.Container, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), do: {:ok, socket} | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(view_table: true, search: search) |> display_containers()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(view_table: true, search: nil) |> display_containers()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
| @@ -21,7 +26,7 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|     %{name: container_name} = | ||||
|       container = | ||||
|       Containers.get_container!(id, current_user) | ||||
|       |> Repo.preload([:tags, :ammo_groups], force: true) | ||||
|       |> Repo.preload([:tags, :ammo_groups]) | ||||
|  | ||||
|     socket | ||||
|     |> assign(page_title: gettext("Edit %{name}", name: container_name), container: container) | ||||
| @@ -31,11 +36,29 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|     socket |> assign(:page_title, gettext("New Container")) |> assign(:container, %Container{}) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do | ||||
|     container = Containers.get_container!(id, current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign(page_title: gettext("New Container"), container: %{container | id: nil}) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Containers")) | ||||
|     |> assign(:container, nil) | ||||
|     |> display_containers() | ||||
|     |> assign( | ||||
|       page_title: gettext("Containers"), | ||||
|       container: nil, | ||||
|       search: nil | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Containers"), | ||||
|       container: nil, | ||||
|       search: search | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do | ||||
| @@ -83,10 +106,26 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_containers(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) |> Repo.preload([:tags, :ammo_groups], force: true) | ||||
|   @impl true | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table) |> display_containers()} | ||||
|   end | ||||
|  | ||||
|     socket |> assign(containers: containers) | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, | ||||
|      socket |> push_patch(to: Routes.container_index_path(Endpoint, :search, search_term))} | ||||
|   end | ||||
|  | ||||
|   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     containers = | ||||
|       Containers.list_containers(search, current_user) | ||||
|       |> Repo.preload([:tags, :ammo_groups]) | ||||
|  | ||||
|     socket |> assign(:containers, containers) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,79 +3,169 @@ | ||||
|     <%= gettext("Containers") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @containers |> Enum.empty?() and @search |> is_nil() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No containers") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|  | ||||
|     <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Add your first container!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "New Container") %> | ||||
|     </.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-xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="container_search" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           value: @search, | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search containers") | ||||
|         ) %> | ||||
|       </.form> | ||||
|  | ||||
|       <.toggle_button action="toggle_table" value={@view_table}> | ||||
|         <span class="title text-lg text-primary-600"> | ||||
|           <%= gettext("View as table") %> | ||||
|         </span> | ||||
|       </.toggle_button> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|   <%= if @containers |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No containers") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= live_patch(dgettext("actions", "Add your first container!"), | ||||
|       to: Routes.container_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|   <% else %> | ||||
|     <%= live_patch(dgettext("actions", "New Container"), | ||||
|       to: Routes.container_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="max-w-full flex flex-row flex-wrap justify-center items-center"> | ||||
|     <%= for container <- @containers do %> | ||||
|       <.container_card container={container}> | ||||
|         <:tag_actions> | ||||
|     <%= if @view_table do %> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.ContainerTableComponent} | ||||
|         id="containers_index_table" | ||||
|         action={@live_action} | ||||
|         containers={@containers} | ||||
|         current_user={@current_user} | ||||
|       > | ||||
|         <:tag_actions :let={container}> | ||||
|           <div class="mx-4 my-2"> | ||||
|             <%= live_patch to: Routes.container_index_path(Endpoint, :edit_tags, container), | ||||
|               class: "text-primary-600 link" do %> | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|               class="text-primary-600 link" | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|             <% end %> | ||||
|             </.link> | ||||
|           </div> | ||||
|         </:tag_actions> | ||||
|         <%= live_patch to: Routes.container_index_path(Endpoint, :edit, container), | ||||
|                    class: "text-primary-600 link", | ||||
|                    data: [qa: "edit-#{container.id}"] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|         <% end %> | ||||
|         <:actions :let={container}> | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"edit-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|  | ||||
|         <%= link to: "#", | ||||
|              class: "text-primary-600 link", | ||||
|              phx_click: "delete", | ||||
|              phx_value_id: container.id, | ||||
|              data: [ | ||||
|                confirm: | ||||
|                  dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name), | ||||
|                qa: "delete-#{container.id}" | ||||
|              ] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         <% end %> | ||||
|       </.container_card> | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"clone-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={container.id} | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|             } | ||||
|             data-qa={"delete-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </:actions> | ||||
|       </.live_component> | ||||
|     <% else %> | ||||
|       <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|         <.container_card :for={container <- @containers} container={container}> | ||||
|           <:tag_actions> | ||||
|             <div class="mx-4 my-2"> | ||||
|               <.link | ||||
|                 patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|                 class="text-primary-600 link" | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|               </.link> | ||||
|             </div> | ||||
|           </:tag_actions> | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"edit-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|             class="text-primary-600 link" | ||||
|             data-qa={"clone-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={container.id} | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|             } | ||||
|             data-qa={"delete-#{container.id}"} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </.container_card> | ||||
|       </div> | ||||
|     <% end %> | ||||
|   </div> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:new, :edit] do %> | ||||
|   <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.ContainerLive.FormComponent} | ||||
|       id={@container.id || :new} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       container={@container} | ||||
|       return_to={Routes.container_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action in [:new, :edit, :clone]} | ||||
|   return_to={Routes.container_index_path(Endpoint, :index)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.ContainerLive.FormComponent} | ||||
|     id={@container.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     container={@container} | ||||
|     return_to={Routes.container_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|  | ||||
| <%= if @live_action == :edit_tags do %> | ||||
|   <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|       id={@container.id} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       container={@container} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal :if={@live_action == :edit_tags} return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|   <.live_component | ||||
|     module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|     id={@container.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     container={@container} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -5,21 +5,23 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.{AmmoGroupCard, TagCard} | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo, Tags} | ||||
|   alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container, Repo, Tags} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), do: {:ok, socket} | ||||
|   def mount(_params, _session, socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: true)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params( | ||||
|         %{"id" => id}, | ||||
|         _session, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     {:noreply, socket |> render_container(id, current_user)} | ||||
|   def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(view_table: true) | ||||
|       |> render_container(id, current_user) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -39,7 +41,7 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|               container_name: container.name | ||||
|             ) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> render_container(container.id, current_user) | ||||
|           socket |> put_flash(:info, prompt) |> render_container() | ||||
|  | ||||
|         {:error, error_string} -> | ||||
|           socket |> put_flash(:error, error_string) | ||||
| @@ -62,7 +64,7 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|  | ||||
|           socket | ||||
|           |> put_flash(:info, prompt) | ||||
|           |> push_redirect(to: Routes.container_index_path(socket, :index)) | ||||
|           |> push_navigate(to: Routes.container_index_path(socket, :index)) | ||||
|  | ||||
|         {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} -> | ||||
|           ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ") | ||||
| @@ -82,20 +84,43 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() | ||||
|   defp render_container(%{assigns: %{live_action: live_action}} = socket, id, current_user) do | ||||
|   defp render_container( | ||||
|          %{assigns: %{live_action: live_action, show_used: show_used}} = socket, | ||||
|          id, | ||||
|          current_user | ||||
|        ) do | ||||
|     %{name: container_name} = | ||||
|       container = | ||||
|       Containers.get_container!(id, current_user) | ||||
|       |> Repo.preload([:ammo_groups, :tags], force: true) | ||||
|       |> Repo.preload([:tags], force: true) | ||||
|  | ||||
|     ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used) | ||||
|  | ||||
|     page_title = | ||||
|       case live_action do | ||||
|         :show -> gettext("Show %{name}", name: container_name) | ||||
|         action when action in [:show, :table] -> container_name | ||||
|         :edit -> gettext("Edit %{name}", name: container_name) | ||||
|         :edit_tags -> gettext("Edit %{name} tags", name: container_name) | ||||
|       end | ||||
|  | ||||
|     socket |> assign(container: container, page_title: page_title) | ||||
|     socket |> assign(container: container, ammo_groups: ammo_groups, page_title: page_title) | ||||
|   end | ||||
|  | ||||
|   @spec render_container(Socket.t()) :: Socket.t() | ||||
|   defp render_container( | ||||
|          %{assigns: %{container: %{id: container_id}, current_user: current_user}} = socket | ||||
|        ) do | ||||
|     socket |> render_container(container_id, current_user) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,56 +1,63 @@ | ||||
| <div class="mx-auto space-y-4 max-w-3xl flex flex-col justify-center items-center"> | ||||
| <div class="space-y-4 flex flex-col justify-center items-center"> | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= @container.name %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @container.desc do %> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Description:") %> | ||||
|       <%= @container.desc %> | ||||
|     </span> | ||||
|   <% end %> | ||||
|   <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||
|     <%= gettext("Description:") %> | ||||
|     <%= @container.desc %> | ||||
|   </span> | ||||
|  | ||||
|   <span class="rounded-lg title text-lg"> | ||||
|     <%= gettext("Type:") %> | ||||
|     <%= @container.type %> | ||||
|   </span> | ||||
|  | ||||
|   <%= if @container.location do %> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Location:") %> | ||||
|       <%= @container.location %> | ||||
|     </span> | ||||
|   <% end %> | ||||
|   <span :if={@container.location} class="rounded-lg title text-lg"> | ||||
|     <%= gettext("Location:") %> | ||||
|     <%= @container.location %> | ||||
|   </span> | ||||
|  | ||||
|   <%= unless @container.ammo_groups |> Enum.empty?() do %> | ||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Packs:") %> | ||||
|       <%= @container |> Containers.get_container_ammo_group_count!() %> | ||||
|       <%= if @show_used do %> | ||||
|         <%= gettext("Total packs:") %> | ||||
|       <% else %> | ||||
|         <%= gettext("Packs:") %> | ||||
|       <% end %> | ||||
|       <%= Enum.count(@ammo_groups) %> | ||||
|     </span> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Rounds:") %> | ||||
|       <%= if @show_used do %> | ||||
|         <%= gettext("Total rounds:") %> | ||||
|       <% else %> | ||||
|         <%= gettext("Rounds:") %> | ||||
|       <% end %> | ||||
|       <%= @container |> Containers.get_container_rounds!() %> | ||||
|     </span> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> | ||||
|     <%= live_patch to: Routes.container_show_path(Endpoint, :edit, @container), | ||||
|                class: "text-primary-600 link", | ||||
|                data: [qa: "edit"] do %> | ||||
|     <.link | ||||
|       patch={Routes.container_show_path(Endpoint, :edit, @container)} | ||||
|       class="text-primary-600 link" | ||||
|       data-qa="edit" | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|     <% end %> | ||||
|     </.link> | ||||
|  | ||||
|     <%= link to: "#", | ||||
|          class: "text-primary-600 link", | ||||
|          phx_click: "delete_container", | ||||
|          data: [ | ||||
|            confirm: | ||||
|              dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name), | ||||
|            qa: "delete" | ||||
|          ] do %> | ||||
|     <.link | ||||
|       href="#" | ||||
|       class="text-primary-600 link" | ||||
|       phx-click="delete_container" | ||||
|       data-confirm={ | ||||
|         dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) | ||||
|       } | ||||
|       data-qa="delete" | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|     <% end %> | ||||
|     </.link> | ||||
|   </div> | ||||
|  | ||||
|   <hr class="mb-4 hr" /> | ||||
| @@ -62,67 +69,98 @@ | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|  | ||||
|       <%= live_patch(dgettext("actions", "Why not add one?"), | ||||
|         to: Routes.container_show_path(Endpoint, :edit_tags, @container), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|       <.link | ||||
|         patch={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|         class="btn btn-primary" | ||||
|       > | ||||
|         <%= dgettext("actions", "Why not add one?") %> | ||||
|       </.link> | ||||
|     </div> | ||||
|   <% else %> | ||||
|     <div class="flex flex-wrap justify-center items-center"> | ||||
|       <%= for tag <- @container.tags do %> | ||||
|         <.simple_tag_card tag={tag} /> | ||||
|       <% end %> | ||||
|       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||
|  | ||||
|       <div class="mx-4 my-2"> | ||||
|         <%= live_patch to: Routes.container_show_path(Endpoint, :edit_tags, @container), | ||||
|           class: "text-primary-600 link" do %> | ||||
|         <.link | ||||
|           patch={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|           class="text-primary-600 link" | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|         <% end %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|   <hr class="mb-4 hr" /> | ||||
|  | ||||
|   <div> | ||||
|     <%= if @container.ammo_groups |> Enum.empty?() do %> | ||||
|       <h2 class="mx-8 my-4 title text-lg text-primary-600"> | ||||
|   <div class="flex justify-center items-center space-x-4"> | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|  | ||||
|     <.toggle_button action="toggle_table" value={@view_table}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("View as table") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|   </div> | ||||
|  | ||||
|   <div class="w-full p-4"> | ||||
|     <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|       <h2 class="mx-4 title text-lg text-primary-600 text-center"> | ||||
|         <%= gettext("No ammo in this container") %> | ||||
|       </h2> | ||||
|     <% else %> | ||||
|       <div class="flex flex-wrap justify-center items-center"> | ||||
|         <%= for ammo_group <- @container.ammo_groups do %> | ||||
|           <.ammo_group_card ammo_group={ammo_group} /> | ||||
|         <% end %> | ||||
|       </div> | ||||
|       <%= if @view_table do %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.AmmoGroupTableComponent} | ||||
|           id="ammo-type-show-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           current_user={@current_user} | ||||
|         > | ||||
|           <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|             <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
|               <%= ammo_type_name %> | ||||
|             </.link> | ||||
|           </:ammo_type> | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="flex flex-wrap justify-center items-stretch"> | ||||
|           <.ammo_group_card :for={ammo_group <- @ammo_groups} ammo_group={ammo_group} /> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:edit] do %> | ||||
|   <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.ContainerLive.FormComponent} | ||||
|       id={@container.id} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       container={@container} | ||||
|       return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action == :edit} | ||||
|   return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.ContainerLive.FormComponent} | ||||
|     id={@container.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     container={@container} | ||||
|     return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|  | ||||
| <%= if @live_action == :edit_tags do %> | ||||
|   <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|       id={@container.id} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       container={@container} | ||||
|       return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action == :edit_tags} | ||||
|   return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|     id={@container.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     container={@container} | ||||
|     return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -7,187 +7,12 @@ defmodule CanneryWeb.HomeLive do | ||||
|   alias Cannery.Accounts | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @version Mix.Project.config()[:version] | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     admins = Accounts.list_users_by_role(:admin) | ||||
|     socket = socket |> assign(page_title: "Home", query: "", results: %{}, admins: admins) | ||||
|     socket = socket |> assign(page_title: gettext("Home"), admins: admins, version: @version) | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("suggest", %{"q" => query}, socket) do | ||||
|     {:noreply, socket |> assign(results: search(query), query: query)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"q" => query}, socket) do | ||||
|     case search(query) do | ||||
|       %{^query => vsn} -> | ||||
|         {:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")} | ||||
|  | ||||
|       _no_query -> | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> put_flash(:error, "No dependencies found matching \"#{query}\"") | ||||
|          |> assign(results: %{}, query: query)} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> | ||||
|       <img | ||||
|         src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|         alt={gettext("Cannery logo")} | ||||
|         class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" | ||||
|         title={gettext("isn't he cute >:3")} | ||||
|       /> | ||||
|  | ||||
|       <h1 class="title text-primary-600 text-2xl"> | ||||
|         <%= gettext("Welcome to %{name}", name: "Cannery") %> | ||||
|       </h1> | ||||
|  | ||||
|       <h2 class="title text-primary-600 text-lg"> | ||||
|         <%= gettext("The self-hosted firearm tracker website") %> | ||||
|       </h2> | ||||
|  | ||||
|       <hr class="hr" /> | ||||
|  | ||||
|       <ul class="flex flex-col space-y-4 text-center"> | ||||
|         <li class="flex flex-col justify-center items-center | ||||
|           space-y-2"> | ||||
|           <b class="whitespace-nowrap"> | ||||
|             <%= gettext("Easy to Use:") %> | ||||
|           </b> | ||||
|           <p> | ||||
|             <%= gettext( | ||||
|               "%{name} lets you easily keep an eye on your ammo levels before and after range day", | ||||
|               name: "Cannery" | ||||
|             ) %> | ||||
|           </p> | ||||
|         </li> | ||||
|         <li class="flex flex-col justify-center items-center | ||||
|           space-y-2"> | ||||
|           <b class="whitespace-nowrap"> | ||||
|             <%= gettext("Secure:") %> | ||||
|           </b> | ||||
|           <p> | ||||
|             <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %> | ||||
|             <%= gettext("Your data stays with you, period") %> | ||||
|           </p> | ||||
|         </li> | ||||
|         <li class="flex flex-col justify-center items-center | ||||
|           space-y-2"> | ||||
|           <b class="whitespace-nowrap"> | ||||
|             <%= gettext("Simple:") %> | ||||
|           </b> | ||||
|           <p> | ||||
|             <%= gettext("Access from any internet-capable device") %> | ||||
|           </p> | ||||
|         </li> | ||||
|       </ul> | ||||
|  | ||||
|       <hr class="hr" /> | ||||
|  | ||||
|       <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|         <h2 class="title text-primary-600 text-lg"> | ||||
|           <%= gettext("Instance Information") %> | ||||
|         </h2> | ||||
|  | ||||
|         <li class="flex flex-col justify-center space-x-2"> | ||||
|           <b> | ||||
|             <%= gettext("Admins:") %> | ||||
|           </b> | ||||
|           <p> | ||||
|             <%= if @admins |> Enum.empty?() do %> | ||||
|               <%= link(dgettext("prompts", "Register to setup %{name}", name: "Cannery"), | ||||
|                 class: "hover:underline", | ||||
|                 to: Routes.user_registration_path(CanneryWeb.Endpoint, :new) | ||||
|               ) %> | ||||
|             <% else %> | ||||
|               <div class="flex flex-wrap justify-center space-x-2"> | ||||
|                 <%= for admin <- @admins do %> | ||||
|                   <a class="hover:underline" href={"mailto:#{admin.email}"}> | ||||
|                     <%= admin.email %> | ||||
|                   </a> | ||||
|                 <% end %> | ||||
|               </div> | ||||
|             <% end %> | ||||
|           </p> | ||||
|         </li> | ||||
|  | ||||
|         <li class="flex flex-row justify-center space-x-2"> | ||||
|           <b>Registration:</b> | ||||
|           <p> | ||||
|             <%= Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] | ||||
|             |> case do | ||||
|               "public" -> gettext("Public Signups") | ||||
|               _ -> gettext("Invite Only") | ||||
|             end %> | ||||
|           </p> | ||||
|         </li> | ||||
|  | ||||
|         <li class="flex flex-row justify-center items-center space-x-2"> | ||||
|           <b>Version:</b> | ||||
|           <%= link class: "flex flex-row justify-center items-center space-x-2 hover:underline", | ||||
|                 to: "https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md", | ||||
|                 target: "_blank", | ||||
|                 rel: "noopener noreferrer" do %> | ||||
|             <p>0.5.4</p> | ||||
|             <i class="fas fa-md fa-info-circle"></i> | ||||
|           <% end %> | ||||
|         </li> | ||||
|       </ul> | ||||
|  | ||||
|       <hr class="hr" /> | ||||
|  | ||||
|       <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|         <h2 class="title text-primary-600 text-lg"> | ||||
|           <%= gettext("Get involved!") %> | ||||
|         </h2> | ||||
|  | ||||
|         <li class="flex flex-col justify-center space-x-2"> | ||||
|           <%= link class: "flex flex-row justify-center items-center space-x-2 hover:underline", | ||||
|                 to: "https://gitea.bubbletea.dev/shibao/cannery", | ||||
|                 target: "_blank", | ||||
|                 rel: "noopener noreferrer" do %> | ||||
|             <p><%= gettext("View the source code") %></p> | ||||
|             <i class="fas fa-md fa-code"></i> | ||||
|           <% end %> | ||||
|         </li> | ||||
|         <li class="flex flex-col justify-center space-x-2"> | ||||
|           <%= link class: "flex flex-row justify-center items-center space-x-2 hover:underline", | ||||
|               to: "https://weblate.bubbletea.dev/engage/cannery", | ||||
|               target: "_blank", | ||||
|               rel: "noopener noreferrer" do %> | ||||
|             <p><%= gettext("Help translate") %></p> | ||||
|             <i class="fas fa-md fa-language"></i> | ||||
|           <% end %> | ||||
|         </li> | ||||
|         <li class="flex flex-col justify-center space-x-2"> | ||||
|           <%= link class: "flex flex-row justify-center items-center space-x-2 hover:underline", | ||||
|               to: "https://gitea.bubbletea.dev/shibao/cannery/issues/new", | ||||
|               target: "_blank", | ||||
|               rel: "noopener noreferrer" do %> | ||||
|             <p><%= gettext("Report bugs or request features") %></p> | ||||
|             <i class="fas fa-md fa-spider"></i> | ||||
|           <% end %> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp search(query) do | ||||
|     if not CanneryWeb.Endpoint.config(:code_reloader) do | ||||
|       raise "action disabled when not in development" | ||||
|     end | ||||
|  | ||||
|     for {app, desc, vsn} <- Application.started_applications(), | ||||
|         app = to_string(app), | ||||
|         String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"), | ||||
|         into: %{}, | ||||
|         do: {app, vsn} | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										144
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> | ||||
|   <img | ||||
|     src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|     alt={gettext("Cannery logo")} | ||||
|     class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" | ||||
|     title={gettext("isn't he cute >:3")} | ||||
|   /> | ||||
|  | ||||
|   <h1 class="title text-primary-600 text-2xl"> | ||||
|     <%= gettext("Welcome to Cannery") %> | ||||
|   </h1> | ||||
|  | ||||
|   <h2 class="title text-primary-600 text-lg"> | ||||
|     <%= gettext("The self-hosted firearm tracker website") %> | ||||
|   </h2> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-4 text-center"> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Easy to Use:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext( | ||||
|           "Cannery lets you easily keep an eye on your ammo levels before and after range day" | ||||
|         ) %> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center items-center space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Secure:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %> | ||||
|         <%= gettext("Your data stays with you, period") %> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Simple:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext("Access from any internet-capable device") %> | ||||
|       </p> | ||||
|     </li> | ||||
|   </ul> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|     <h2 class="title text-primary-600 text-lg"> | ||||
|       <%= gettext("Instance Information") %> | ||||
|     </h2> | ||||
|  | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <b> | ||||
|         <%= gettext("Admins:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= if @admins |> Enum.empty?() do %> | ||||
|           <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline"> | ||||
|             <%= dgettext("prompts", "Register to setup Cannery") %> | ||||
|           </.link> | ||||
|         <% else %> | ||||
|           <div class="flex flex-wrap justify-center space-x-2"> | ||||
|             <a :for={%{email: email} <- @admins} class="hover:underline" href={"mailto:#{email}"}> | ||||
|               <%= email %> | ||||
|             </a> | ||||
|           </div> | ||||
|         <% end %> | ||||
|       </p> | ||||
|     </li> | ||||
|  | ||||
|     <li class="flex flex-row justify-center space-x-2"> | ||||
|       <b><%= gettext("Registration:") %></b> | ||||
|       <p> | ||||
|         <%= case Application.get_env(:cannery, Cannery.Accounts)[:registration] do | ||||
|           "public" -> gettext("Public Signups") | ||||
|           _ -> gettext("Invite Only") | ||||
|         end %> | ||||
|       </p> | ||||
|     </li> | ||||
|  | ||||
|     <li class="flex flex-row justify-center items-center space-x-2"> | ||||
|       <b><%= gettext("Version:") %></b> | ||||
|       <.link | ||||
|         href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md" | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p> | ||||
|           <%= @version %> | ||||
|         </p> | ||||
|         <i class="fas fa-md fa-info-circle"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|   </ul> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|     <h2 class="title text-primary-600 text-lg"> | ||||
|       <%= gettext("Get involved!") %> | ||||
|     </h2> | ||||
|  | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://gitea.bubbletea.dev/shibao/cannery" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("View the source code") %></p> | ||||
|         <i class="fas fa-md fa-code"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://weblate.bubbletea.dev/engage/cannery" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("Help translate") %></p> | ||||
|         <i class="fas fa-md fa-language"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://gitea.bubbletea.dev/shibao/cannery/issues/new" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("Report bugs or request features") %></p> | ||||
|         <i class="fas fa-md fa-spider"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|   </ul> | ||||
| </div> | ||||
| @@ -2,7 +2,7 @@ defmodule CanneryWeb.InitAssigns do | ||||
|   @moduledoc """ | ||||
|   Ensures common `assigns` are applied to all LiveViews attaching this hook. | ||||
|   """ | ||||
|   import Phoenix.LiveView | ||||
|   import Phoenix.Component | ||||
|   alias Cannery.Accounts | ||||
|  | ||||
|   def on_mount(:default, _params, %{"locale" => locale, "user_token" => user_token}, socket) do | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| defmodule CanneryWeb.InviteLive.FormComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can update or create an Cannery.Invites.Invite | ||||
|   Livecomponent that can update or create an Cannery.Accounts.Invite | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Invites, Invites.Invite} | ||||
|   alias Ecto.Changeset | ||||
|   alias Cannery.Accounts.{Invite, Invites, User} | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
| @@ -38,7 +38,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> invite |> Invite.create_changeset(user, "example_token", invite_params) | ||||
|         :new -> Invite.create_changeset(user, "example_token", invite_params) | ||||
|         :edit -> invite |> Invite.update_changeset(invite_params) | ||||
|       end | ||||
|  | ||||
| @@ -60,7 +60,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do | ||||
|       case invite |> Invites.update_invite(invite_params, current_user) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
| @@ -78,7 +78,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do | ||||
|       case current_user |> Invites.create_invite(invite_params) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="invite-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
| @@ -11,11 +11,12 @@ | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
| @@ -25,7 +26,7 @@ | ||||
|     <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :uses_left, "col-span-3") %> | ||||
|     <span class="col-span-3 text-primary-400 italic text-center"> | ||||
|       <%= gettext("Leave \"Uses left\" blank to make invite unlimited") %> | ||||
|       <%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %> | ||||
|     </span> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| defmodule CanneryWeb.InviteLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview to show a Cannery.Invites.Invite index | ||||
|   Liveview to show a Cannery.Accounts.Invite index | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.{InviteCard, UserCard} | ||||
|   alias Cannery.{Accounts, Invites, Invites.Invite} | ||||
|   alias Cannery.Accounts | ||||
|   alias Cannery.Accounts.{Invite, Invites} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
| @@ -50,7 +51,7 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|     %{name: invite_name} = | ||||
|       id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: invite_name) | ||||
|     prompt = dgettext("prompts", "%{invite_name} deleted succesfully", invite_name: invite_name) | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_invites()} | ||||
|   end | ||||
|  | ||||
| @@ -61,10 +62,12 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|       ) do | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{"uses_left" => nil}, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: nil}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated succesfully", name: invite_name) | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} updated succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
| @@ -81,10 +84,12 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|       ) do | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{"uses_left" => nil, "disabled_at" => nil}, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} enabled succesfully", name: invite_name) | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} enabled succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
| @@ -103,10 +108,12 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|  | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{"uses_left" => 0, "disabled_at" => now}, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} disabled succesfully", name: invite_name) | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} disabled succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
| @@ -130,7 +137,7 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|       ) do | ||||
|     %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: user_email) | ||||
|     prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_invites()} | ||||
|   end | ||||
|   | ||||
| @@ -9,78 +9,75 @@ | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h1> | ||||
|  | ||||
|     <%= live_patch(dgettext("actions", "Invite someone new!"), | ||||
|       to: Routes.invite_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Invite someone new!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <%= live_patch(dgettext("actions", "Create Invite"), | ||||
|       to: Routes.invite_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Create Invite") %> | ||||
|     </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-row flex-wrap justify-center items-center"> | ||||
|     <%= for invite <- @invites do %> | ||||
|       <.invite_card invite={invite}> | ||||
|         <:code_actions> | ||||
|           <form phx-submit="copy_to_clipboard"> | ||||
|             <button | ||||
|               type="submit" | ||||
|               class="mx-2 my-1 btn btn-primary" | ||||
|               phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")} | ||||
|             > | ||||
|               <%= dgettext("actions", "Copy to clipboard") %> | ||||
|             </button> | ||||
|           </form> | ||||
|         </:code_actions> | ||||
|         <%= live_patch to: Routes.invite_index_path(Endpoint, :edit, invite), | ||||
|                    class: "text-primary-600 link", | ||||
|                    data: [qa: "edit-#{invite.id}"] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|         <% end %> | ||||
|  | ||||
|         <%= link to: "#", | ||||
|              class: "text-primary-600 link", | ||||
|              phx_click: "delete_invite", | ||||
|              phx_value_id: invite.id, | ||||
|              data: [ | ||||
|                confirm: | ||||
|                  dgettext("prompts", "Are you sure you want to delete the invite for %{name}?", | ||||
|                    name: invite.name | ||||
|                  ), | ||||
|                qa: "delete-#{invite.id}" | ||||
|              ] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         <% end %> | ||||
|  | ||||
|         <%= if invite.disabled_at |> is_nil() do %> | ||||
|           <a href="#" class="btn btn-primary" phx-click="disable_invite" phx-value-id={invite.id}> | ||||
|             <%= gettext("Disable") %> | ||||
|           </a> | ||||
|         <% else %> | ||||
|           <a href="#" class="btn btn-primary" phx-click="enable_invite" phx-value-id={invite.id}> | ||||
|             <%= gettext("Enable") %> | ||||
|           </a> | ||||
|         <% end %> | ||||
|  | ||||
|         <%= if invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil()) do %> | ||||
|           <a | ||||
|             href="#" | ||||
|             class="btn btn-primary" | ||||
|             phx-click="set_unlimited" | ||||
|             phx-value-id={invite.id} | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to make %{name} unlimited?", | ||||
|                 name: invite.name | ||||
|               ) | ||||
|             } | ||||
|   <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|     <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}> | ||||
|       <:code_actions> | ||||
|         <form phx-submit="copy_to_clipboard"> | ||||
|           <button | ||||
|             type="submit" | ||||
|             class="mx-2 my-1 btn btn-primary" | ||||
|             phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")} | ||||
|           > | ||||
|             <%= gettext("Set Unlimited") %> | ||||
|           </a> | ||||
|         <% end %> | ||||
|       </.invite_card> | ||||
|     <% end %> | ||||
|             <%= dgettext("actions", "Copy to clipboard") %> | ||||
|           </button> | ||||
|         </form> | ||||
|       </:code_actions> | ||||
|       <.link | ||||
|         patch={Routes.invite_index_path(Endpoint, :edit, invite)} | ||||
|         class="text-primary-600 link" | ||||
|         data-qa={"edit-#{invite.id}"} | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       </.link> | ||||
|  | ||||
|       <.link | ||||
|         href="#" | ||||
|         class="text-primary-600 link" | ||||
|         phx-click="delete_invite" | ||||
|         phx-value-id={invite.id} | ||||
|         data-confirm={ | ||||
|           dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?", | ||||
|             invite_name: invite.name | ||||
|           ) | ||||
|         } | ||||
|         data-qa={"delete-#{invite.id}"} | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       </.link> | ||||
|  | ||||
|       <a | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
|         phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"} | ||||
|         phx-value-id={invite.id} | ||||
|       > | ||||
|         <%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %> | ||||
|       </a> | ||||
|  | ||||
|       <a | ||||
|         :if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())} | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
|         phx-click="set_unlimited" | ||||
|         phx-value-id={invite.id} | ||||
|         data-confirm={ | ||||
|           dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?", | ||||
|             invite_name: invite.name | ||||
|           ) | ||||
|         } | ||||
|       > | ||||
|         <%= dgettext("actions", "Set Unlimited") %> | ||||
|       </a> | ||||
|     </.invite_card> | ||||
|   </div> | ||||
|  | ||||
|   <%= unless @admins |> Enum.empty?() do %> | ||||
| @@ -90,25 +87,24 @@ | ||||
|       <%= gettext("Admins") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-center"> | ||||
|       <%= for admin <- @admins do %> | ||||
|         <.user_card user={admin}> | ||||
|           <%= link to: "#", | ||||
|                class: "text-primary-600 link", | ||||
|                phx_click: "delete_user", | ||||
|                phx_value_id: admin.id, | ||||
|                data: [ | ||||
|                  confirm: | ||||
|                    dgettext( | ||||
|                      "prompts", | ||||
|                      "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|                      email: admin.email | ||||
|                    ) | ||||
|                ] do %> | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           <% end %> | ||||
|         </.user_card> | ||||
|       <% end %> | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.user_card :for={admin <- @admins} user={admin}> | ||||
|         <.link | ||||
|           href="#" | ||||
|           class="text-primary-600 link" | ||||
|           phx-click="delete_user" | ||||
|           phx-value-id={admin.id} | ||||
|           data-confirm={ | ||||
|             dgettext( | ||||
|               "prompts", | ||||
|               "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|               email: admin.email | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         </.link> | ||||
|       </.user_card> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
| @@ -119,39 +115,36 @@ | ||||
|       <%= gettext("Users") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-center"> | ||||
|       <%= for user <- @users do %> | ||||
|         <.user_card user={user}> | ||||
|           <%= link to: "#", | ||||
|                class: "text-primary-600 link", | ||||
|                phx_click: "delete_user", | ||||
|                phx_value_id: user.id, | ||||
|                data: [ | ||||
|                  confirm: | ||||
|                    dgettext( | ||||
|                      "prompts", | ||||
|                      "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|                      email: user.email | ||||
|                    ) | ||||
|                ] do %> | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           <% end %> | ||||
|         </.user_card> | ||||
|       <% end %> | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.user_card :for={user <- @users} user={user}> | ||||
|         <.link | ||||
|           href="#" | ||||
|           class="text-primary-600 link" | ||||
|           phx-click="delete_user" | ||||
|           phx-value-id={user.id} | ||||
|           data-confirm={ | ||||
|             dgettext( | ||||
|               "prompts", | ||||
|               "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|               email: user.email | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         </.link> | ||||
|       </.user_card> | ||||
|     </div> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:new, :edit] do %> | ||||
|   <.modal return_to={Routes.invite_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.InviteLive.FormComponent} | ||||
|       id={@invite.id || :new} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       invite={@invite} | ||||
|       return_to={Routes.invite_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}> | ||||
|   <.live_component | ||||
|     module={CanneryWeb.InviteLive.FormComponent} | ||||
|     id={@invite.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     invite={@invite} | ||||
|     return_to={Routes.invite_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -3,7 +3,7 @@ defmodule CanneryWeb.LiveHelpers do | ||||
|   Contains common helper functions for liveviews | ||||
|   """ | ||||
|  | ||||
|   import Phoenix.LiveView.Helpers | ||||
|   import Phoenix.Component | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @doc """ | ||||
| @@ -27,17 +27,17 @@ defmodule CanneryWeb.LiveHelpers do | ||||
|   """ | ||||
|   def modal(assigns) do | ||||
|     ~H""" | ||||
|     <%= live_patch to: @return_to, | ||||
|       id: "modal-bg", | ||||
|       class: | ||||
|         "fade-in fixed z-10 left-0 top-0 | ||||
|          w-full h-full overflow-hidden | ||||
|          p-8 flex flex-col justify-center items-center cursor-auto", | ||||
|       style: "background-color: rgba(0,0,0,0.4);", | ||||
|       phx_remove: hide_modal() | ||||
|     do %> | ||||
|     <.link | ||||
|       patch={@return_to} | ||||
|       id="modal-bg" | ||||
|       class="fade-in fixed z-10 left-0 top-0 | ||||
|         w-full h-full overflow-hidden | ||||
|         p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|       style="background-color: rgba(0,0,0,0.4);" | ||||
|       phx-remove={hide_modal()} | ||||
|     > | ||||
|       <span class="hidden"></span> | ||||
|     <% end %> | ||||
|     </.link> | ||||
|  | ||||
|     <div | ||||
|       id="modal" | ||||
| @@ -48,20 +48,21 @@ defmodule CanneryWeb.LiveHelpers do | ||||
|       <div | ||||
|         id="modal-content" | ||||
|         class="fade-in-scale w-full max-w-3xl relative | ||||
|         pointer-events-auto overflow-hidden | ||||
|         px-8 py-4 sm:py-8 flex flex-col justify-center items-center | ||||
|         flex flex-col justify-start items-center | ||||
|         bg-white border-2 rounded-lg" | ||||
|           pointer-events-auto overflow-hidden | ||||
|           px-8 py-4 sm:py-8 | ||||
|           flex flex-col justify-start items-center | ||||
|           bg-white border-2 rounded-lg" | ||||
|       > | ||||
|         <%= live_patch to: @return_to, | ||||
|                    id: "close", | ||||
|                    class: | ||||
|                      "absolute top-8 right-10 | ||||
|                       text-gray-500 hover:text-gray-800 | ||||
|                       transition-all duration-500 ease-in-out", | ||||
|                    phx_remove: hide_modal() do %> | ||||
|         <.link | ||||
|           patch={@return_to} | ||||
|           id="close" | ||||
|           class="absolute top-8 right-10 | ||||
|             text-gray-500 hover:text-gray-800 | ||||
|             transition-all duration-500 ease-in-out" | ||||
|           phx-remove={hide_modal()} | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|         <% end %> | ||||
|         </.link> | ||||
|  | ||||
|         <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
| @@ -71,10 +72,57 @@ defmodule CanneryWeb.LiveHelpers do | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   def hide_modal(js \\ %JS{}) do | ||||
|   defp hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A toggle button element that can be directed to a liveview or a | ||||
|   live_component's `handle_event/3`. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||
|     <span>Toggle me!</span> | ||||
|   </.toggle_button> | ||||
|   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||
|     <span>Whatever you want</span> | ||||
|   </.toggle_button> | ||||
|   """ | ||||
|   def toggle_button(assigns) do | ||||
|     assigns = assigns |> assign_new(:id, fn -> assigns.action end) | ||||
|  | ||||
|     ~H""" | ||||
|     <label for={@id} class="inline-flex relative items-center cursor-pointer"> | ||||
|       <input | ||||
|         id={@id} | ||||
|         type="checkbox" | ||||
|         value={@value} | ||||
|         checked={@value} | ||||
|         class="sr-only peer" | ||||
|         data-qa={@id} | ||||
|         { | ||||
|           if assigns |> Map.has_key?(:target), | ||||
|             do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||
|             else: %{"phx-click": @action, "phx-value-value": @value} | ||||
|         } | ||||
|       /> | ||||
|       <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||
|         peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||
|         peer-checked:bg-gray-600 | ||||
|         peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|         after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 | ||||
|         after:border after:rounded-full after:h-5 after:w-5 | ||||
|         after:transition-all after:duration-250 after:ease-in-out | ||||
|         transition-colors duration-250 ease-in-out"> | ||||
|       </div> | ||||
|       <span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </span> | ||||
|     </label> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -53,7 +53,7 @@ defmodule CanneryWeb.RangeLive.FormComponent do | ||||
|       case ActivityLog.update_shot_group(shot_group, shot_group_params, current_user) do | ||||
|         {:ok, _shot_group} -> | ||||
|           prompt = dgettext("prompts", "Shot records updated successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Ecto.Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|   </h2> | ||||
|  | ||||
|   <.form | ||||
|     let={f} | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="shot-group-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
| @@ -12,11 +12,12 @@ | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="invalid-feedback col-span-3 text-center"> | ||||
|         <%= changeset_errors(@changeset) %> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :count, gettext("Shots fired"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :count, | ||||
| @@ -33,7 +34,7 @@ | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :date, gettext("Date (UTC)"), class: "title text-lg text-primary-600") %> | ||||
|     <%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %> | ||||
|     <%= date_input(f, :date, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,13 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), do: {:ok, socket |> display_shot_groups()} | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(search: search) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(search: nil) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
| @@ -23,26 +29,46 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|          %{"id" => id} | ||||
|        ) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Record Shots")) | ||||
|     |> assign(:ammo_group, Ammo.get_ammo_group!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Record Shots"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Edit Shot Records")) | ||||
|     |> assign(:shot_group, ActivityLog.get_shot_group!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit Shot Records"), | ||||
|       shot_group: ActivityLog.get_shot_group!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("New Shot Records")) | ||||
|     |> assign(:shot_group, %ShotGroup{}) | ||||
|     |> assign( | ||||
|       page_title: gettext("New Shot Records"), | ||||
|       shot_group: %ShotGroup{} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Shot Records")) | ||||
|     |> assign(:shot_group, nil) | ||||
|     |> assign( | ||||
|       page_title: gettext("Shot Records"), | ||||
|       search: nil, | ||||
|       shot_group: nil | ||||
|     ) | ||||
|     |> display_shot_groups() | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Shot Records"), | ||||
|       search: search, | ||||
|       shot_group: nil | ||||
|     ) | ||||
|     |> display_shot_groups() | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -55,6 +81,7 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_staged", | ||||
|         %{"ammo_group_id" => ammo_group_id}, | ||||
| @@ -69,75 +96,46 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :search, search_term))} | ||||
|   end | ||||
|  | ||||
|   @spec display_shot_groups(Socket.t()) :: Socket.t() | ||||
|   defp display_shot_groups(%{assigns: %{current_user: current_user}} = socket) do | ||||
|   defp display_shot_groups(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     shot_groups = | ||||
|       ActivityLog.list_shot_groups(current_user) |> Repo.preload(ammo_group: :ammo_type) | ||||
|       ActivityLog.list_shot_groups(search, current_user) | ||||
|       |> Repo.preload(ammo_group: :ammo_type) | ||||
|  | ||||
|     ammo_groups = Ammo.list_staged_ammo_groups(current_user) | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Ammo"), key: "name"}, | ||||
|       %{label: gettext("Rounds shot"), key: "count"}, | ||||
|       %{label: gettext("Notes"), key: "notes"}, | ||||
|       %{label: gettext("Date"), key: "date"}, | ||||
|       %{label: nil, key: "actions", sortable: false} | ||||
|     ] | ||||
|  | ||||
|     rows = | ||||
|       shot_groups | ||||
|       |> Enum.map(fn shot_group -> shot_group |> get_row_data_for_shot_group(columns) end) | ||||
|     chart_data = shot_groups |> get_chart_data_for_shot_group() | ||||
|  | ||||
|     socket | ||||
|     |> assign(ammo_groups: ammo_groups, columns: columns, rows: rows, shot_groups: shot_groups) | ||||
|     |> assign( | ||||
|       ammo_groups: ammo_groups, | ||||
|       chart_data: chart_data, | ||||
|       shot_groups: shot_groups | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_shot_group(ShotGroup.t(), [map()]) :: [map()] | ||||
|   defp get_row_data_for_shot_group(%{date: date} = shot_group, columns) do | ||||
|     shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type) | ||||
|     assigns = %{shot_group: shot_group} | ||||
|   @spec get_chart_data_for_shot_group([ShotGroup.t()]) :: [map()] | ||||
|   defp get_chart_data_for_shot_group(shot_groups) do | ||||
|     shot_groups | ||||
|     |> Repo.preload(ammo_group: :ammo_type) | ||||
|     |> Enum.group_by(fn %{date: date} -> date end, fn %{count: count} -> count end) | ||||
|     |> Enum.map(fn {date, rounds} -> | ||||
|       sum = Enum.sum(rounds) | ||||
|  | ||||
|     columns | ||||
|     |> Enum.into(%{}, fn %{key: key} -> | ||||
|       value = | ||||
|         case key do | ||||
|           "name" -> | ||||
|             {shot_group.ammo_group.ammo_type.name, | ||||
|              live_patch(shot_group.ammo_group.ammo_type.name, | ||||
|                to: Routes.ammo_group_show_path(Endpoint, :show, shot_group.ammo_group), | ||||
|                class: "link" | ||||
|              )} | ||||
|  | ||||
|           "date" -> | ||||
|             date |> display_date() | ||||
|  | ||||
|           "actions" -> | ||||
|             ~H""" | ||||
|             <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|               <%= live_patch to: Routes.range_index_path(Endpoint, :edit, shot_group), | ||||
|                           class: "text-primary-600 link", | ||||
|                           data: [qa: "edit-#{shot_group.id}"] do %> | ||||
|                 <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|               <% end %> | ||||
|  | ||||
|               <%= link to: "#", | ||||
|                     class: "text-primary-600 link", | ||||
|                     phx_click: "delete", | ||||
|                     phx_value_id: shot_group.id, | ||||
|                     data: [ | ||||
|                       confirm: dgettext("prompts", "Are you sure you want to delete this shot record?"), | ||||
|                       qa: "delete-#{shot_group.id}" | ||||
|                     ] do %> | ||||
|                 <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|               <% end %> | ||||
|             </div> | ||||
|             """ | ||||
|  | ||||
|           key -> | ||||
|             shot_group |> Map.get(key |> String.to_existing_atom()) | ||||
|         end | ||||
|  | ||||
|       {key, value} | ||||
|       %{ | ||||
|         date: date, | ||||
|         count: sum, | ||||
|         label: gettext("Rounds shot: %{count}", count: sum) | ||||
|       } | ||||
|     end) | ||||
|     |> Enum.sort_by(fn %{date: date} -> date end, Date) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -9,18 +9,16 @@ | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h1> | ||||
|  | ||||
|     <%= live_patch(dgettext("actions", "Why not get some ready to shoot?"), | ||||
|       to: Routes.ammo_group_index_path(Endpoint, :index), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link navigate={Routes.ammo_group_index_path(Endpoint, :index)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Why not get some ready to shoot?") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <%= live_patch(dgettext("actions", "Stage ammo"), | ||||
|       to: Routes.ammo_group_index_path(Endpoint, :index), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link navigate={Routes.ammo_group_index_path(Endpoint, :index)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Stage ammo") %> | ||||
|     </.link> | ||||
|  | ||||
|     <%= for ammo_group <- @ammo_groups do %> | ||||
|       <.ammo_group_card ammo_group={ammo_group}> | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.ammo_group_card :for={ammo_group <- @ammo_groups} ammo_group={ammo_group}> | ||||
|         <button | ||||
|           type="button" | ||||
|           class="btn btn-primary" | ||||
| @@ -29,21 +27,23 @@ | ||||
|           data-confirm={"#{dgettext("prompts", "Are you sure you want to unstage this ammo?")}"} | ||||
|         > | ||||
|           <%= if ammo_group.staged, | ||||
|             do: gettext("Unstage from range"), | ||||
|             else: gettext("Stage for range") %> | ||||
|             do: dgettext("actions", "Unstage from range"), | ||||
|             else: dgettext("actions", "Stage for range") %> | ||||
|         </button> | ||||
|  | ||||
|         <%= live_patch(dgettext("actions", "Record shots"), | ||||
|           to: Routes.range_index_path(Endpoint, :add_shot_group, ammo_group), | ||||
|           class: "btn btn-primary" | ||||
|         ) %> | ||||
|         <.link | ||||
|           patch={Routes.range_index_path(Endpoint, :add_shot_group, ammo_group)} | ||||
|           class="btn btn-primary" | ||||
|         > | ||||
|           <%= dgettext("actions", "Record shots") %> | ||||
|         </.link> | ||||
|       </.ammo_group_card> | ||||
|     <% end %> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <%= if @shot_groups |> Enum.empty?() do %> | ||||
|   <%= if @shot_groups |> Enum.empty?() and @search |> is_nil() do %> | ||||
|     <h1 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No shots recorded") %> | ||||
|       <%= display_emoji("😔") %> | ||||
| @@ -53,39 +53,102 @@ | ||||
|       <%= gettext("Shot log") %> | ||||
|     </h1> | ||||
|  | ||||
|     <.live_component | ||||
|       module={CanneryWeb.Components.TableComponent} | ||||
|       id="shot_groups_index_table" | ||||
|       columns={@columns} | ||||
|       rows={@rows} | ||||
|     /> | ||||
|     <canvas | ||||
|       id="shot-log-chart" | ||||
|       phx-hook="ShotLogChart" | ||||
|       phx-update="ignore" | ||||
|       class="max-h-72" | ||||
|       data-chart-data={Jason.encode!(@chart_data)} | ||||
|       data-label={gettext("Rounds shot")} | ||||
|       data-color={random_color()} | ||||
|       aria-label={gettext("Rounds shot chart")} | ||||
|       role="img" | ||||
|     > | ||||
|       <%= dgettext("errors", "Your browser does not support the canvas element.") %> | ||||
|     </canvas> | ||||
|  | ||||
|     <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-xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="shot_group_search" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           value: @search, | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search shot records") | ||||
|         ) %> | ||||
|       </.form> | ||||
|     </div> | ||||
|  | ||||
|     <%= if @shot_groups |> Enum.empty?() do %> | ||||
|       <h1 class="title text-xl text-primary-600"> | ||||
|         <%= gettext("No shots recorded") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h1> | ||||
|     <% else %> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.ShotGroupTableComponent} | ||||
|         id="shot_groups_index_table" | ||||
|         shot_groups={@shot_groups} | ||||
|         current_user={@current_user} | ||||
|       > | ||||
|         <:actions :let={shot_group}> | ||||
|           <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|             <.link | ||||
|               patch={Routes.range_index_path(Endpoint, :edit, shot_group)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"edit-#{shot_group.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               href="#" | ||||
|               class="text-primary-600 link" | ||||
|               phx-click="delete" | ||||
|               phx-value-id={shot_group.id} | ||||
|               data-confirm={ | ||||
|                 dgettext("prompts", "Are you sure you want to delete this shot record?") | ||||
|               } | ||||
|               data-qa={"delete-#{shot_group.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
|           </div> | ||||
|         </:actions> | ||||
|       </.live_component> | ||||
|     <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:edit] do %> | ||||
|   <.modal return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.RangeLive.FormComponent} | ||||
|       id={@shot_group.id} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       shot_group={@shot_group} | ||||
|       return_to={Routes.range_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal :if={@live_action == :edit} return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
|   <.live_component | ||||
|     module={CanneryWeb.RangeLive.FormComponent} | ||||
|     id={@shot_group.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     shot_group={@shot_group} | ||||
|     return_to={Routes.range_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|  | ||||
| <%= if @live_action in [:add_shot_group] do %> | ||||
|   <.modal return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.Components.AddShotGroupComponent} | ||||
|       id={:new} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       ammo_group={@ammo_group} | ||||
|       return_to={Routes.range_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal | ||||
|   :if={@live_action == :add_shot_group} | ||||
|   return_to={Routes.range_index_path(Endpoint, :index)} | ||||
| > | ||||
|   <.live_component | ||||
|     module={CanneryWeb.Components.AddShotGroupComponent} | ||||
|     id={:new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     ammo_group={@ammo_group} | ||||
|     return_to={Routes.range_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -50,53 +50,6 @@ defmodule CanneryWeb.TagLive.FormComponent do | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div> | ||||
|       <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|         <%= @title %> | ||||
|       </h2> | ||||
|       <.form | ||||
|         let={f} | ||||
|         for={@changeset} | ||||
|         id="tag-form" | ||||
|         class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|         phx-target={@myself} | ||||
|         phx-change="validate" | ||||
|         phx-submit="save" | ||||
|       > | ||||
|         <%= if @changeset.action && not @changeset.valid? do %> | ||||
|           <div class="invalid-feedback col-span-3 text-center"> | ||||
|             <%= changeset_errors(@changeset) %> | ||||
|           </div> | ||||
|         <% end %> | ||||
|  | ||||
|         <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|         <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|         <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|         <%= label(f, :bg_color, gettext("Background color"), class: "title text-lg text-primary-600") %> | ||||
|         <span id="tag-bg-color-input" class="mx-auto col-span-2" phx-update="ignore"> | ||||
|           <%= color_input(f, :bg_color) %> | ||||
|         </span> | ||||
|         <%= error_tag(f, :bg_color, "col-span-3") %> | ||||
|  | ||||
|         <%= label(f, :text_color, gettext("Text color"), class: "title text-lg text-primary-600") %> | ||||
|         <span id="tag-text-color-input" class="mx-auto col-span-2" phx-update="ignore"> | ||||
|           <%= color_input(f, :text_color) %> | ||||
|         </span> | ||||
|         <%= error_tag(f, :text_color, "col-span-3") %> | ||||
|  | ||||
|         <%= submit(dgettext("actions", "Save"), | ||||
|           class: "mx-auto btn btn-primary col-span-3", | ||||
|           phx_disable_with: dgettext("prompts", "Saving...") | ||||
|         ) %> | ||||
|       </.form> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp save_tag( | ||||
|          %{assigns: %{tag: tag, current_user: current_user, return_to: return_to}} = socket, | ||||
|          :edit, | ||||
| @@ -106,7 +59,7 @@ defmodule CanneryWeb.TagLive.FormComponent do | ||||
|       case Tags.update_tag(tag, tag_params, current_user) do | ||||
|         {:ok, %{name: tag_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
| @@ -124,7 +77,7 @@ defmodule CanneryWeb.TagLive.FormComponent do | ||||
|       case Tags.create_tag(tag_params, current_user) do | ||||
|         {:ok, %{name: tag_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_redirect(to: return_to) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|   | ||||
							
								
								
									
										42
									
								
								lib/cannery_web/live/tag_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/cannery_web/live/tag_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| <div> | ||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="tag-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|     phx-target={@myself} | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :bg_color, gettext("Background color"), class: "title text-lg text-primary-600") %> | ||||
|     <span id="tag-bg-color-input" class="mx-auto col-span-2" phx-update="ignore"> | ||||
|       <%= color_input(f, :bg_color) %> | ||||
|     </span> | ||||
|     <%= error_tag(f, :bg_color, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :text_color, gettext("Text color"), class: "title text-lg text-primary-600") %> | ||||
|     <span id="tag-text-color-input" class="mx-auto col-span-2" phx-update="ignore"> | ||||
|       <%= color_input(f, :text_color) %> | ||||
|     </span> | ||||
|     <%= error_tag(f, :text_color, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|       class: "mx-auto btn btn-primary col-span-3", | ||||
|       phx_disable_with: dgettext("prompts", "Saving...") | ||||
|     ) %> | ||||
|   </.form> | ||||
| </div> | ||||
| @@ -6,30 +6,56 @@ defmodule CanneryWeb.TagLive.Index do | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.TagCard | ||||
|   alias Cannery.{Tags, Tags.Tag} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias CanneryWeb.{Endpoint, ViewHelpers} | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), do: {:ok, socket |> display_tags()} | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(:search, search) |> display_tags()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(:search, nil) |> display_tags()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, apply_action(socket, live_action, params) |> display_tags} | ||||
|     {:noreply, apply_action(socket, live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("Edit Tag")) | ||||
|     |> assign(:tag, Tags.get_tag!(id, current_user)) | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit Tag"), | ||||
|       tag: Tags.get_tag!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, gettext("New Tag")) | ||||
|     |> assign(:tag, %Tag{bg_color: Tags.random_bg_color(), text_color: "#ffffff"}) | ||||
|     |> assign( | ||||
|       page_title: gettext("New Tag"), | ||||
|       tag: %Tag{bg_color: ViewHelpers.random_color(), text_color: "#ffffff"} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket |> assign(:page_title, gettext("Tags")) |> assign(:tag, nil) | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Tags"), | ||||
|       search: nil, | ||||
|       tag: nil | ||||
|     ) | ||||
|     |> display_tags() | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Tags"), | ||||
|       search: search, | ||||
|       tag: nil | ||||
|     ) | ||||
|     |> display_tags() | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -39,7 +65,16 @@ defmodule CanneryWeb.TagLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_tags()} | ||||
|   end | ||||
|  | ||||
|   defp display_tags(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket |> assign(tags: Tags.list_tags(current_user)) | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.tag_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.tag_index_path(Endpoint, :search, search_term))} | ||||
|   end | ||||
|  | ||||
|   defp display_tags(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     socket |> assign(tags: Tags.list_tags(search, current_user)) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -5,56 +5,80 @@ | ||||
|   <p class="title text-md text-primary-600"> | ||||
|     <%= gettext("Tags can be added to your containers to help you organize") %> | ||||
|   </p> | ||||
|   <%= if @tags |> Enum.empty?() do %> | ||||
|   <%= if @tags |> Enum.empty?() and @search |> is_nil() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No tags") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= live_patch(dgettext("actions", "Make your first tag!"), | ||||
|       to: Routes.tag_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.tag_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Make your first tag!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <%= live_patch(dgettext("actions", "New Tag"), | ||||
|       to: Routes.tag_index_path(Endpoint, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link patch={Routes.tag_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "New Tag") %> | ||||
|     </.link> | ||||
|   <% end %> | ||||
|   <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|     <%= for tag <- @tags do %> | ||||
|       <.tag_card tag={tag}> | ||||
|         <%= live_patch to: Routes.tag_index_path(Endpoint, :edit, tag), | ||||
|                    class: "text-primary-600 link", | ||||
|                    data: [qa: "edit-#{tag.id}"] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|         <% end %> | ||||
|  | ||||
|         <%= link to: "#", | ||||
|              class: "text-primary-600 link", | ||||
|              phx_click: "delete", | ||||
|              phx_value_id: tag.id, | ||||
|              data: [ | ||||
|                confirm: dgettext("prompts", "Are you sure you want to delete %{name}?", name: tag.name), | ||||
|                qa: "delete-#{tag.id}" | ||||
|              ] do %> | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         <% end %> | ||||
|       </.tag_card> | ||||
|     <% end %> | ||||
|   <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-xl"> | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={:search} | ||||
|       phx-change="search" | ||||
|       phx-submit="search" | ||||
|       class="grow self-stretch flex flex-col items-stretch" | ||||
|       data-qa="tag_search" | ||||
|     > | ||||
|       <%= text_input(f, :search_term, | ||||
|         class: "input input-primary", | ||||
|         value: @search, | ||||
|         phx_debounce: 300, | ||||
|         placeholder: gettext("Search tags") | ||||
|       ) %> | ||||
|     </.form> | ||||
|   </div> | ||||
|  | ||||
|   <%= if @tags |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No tags") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <div class="flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.tag_card :for={tag <- @tags} tag={tag}> | ||||
|         <.link | ||||
|           patch={Routes.tag_index_path(Endpoint, :edit, tag)} | ||||
|           class="text-primary-600 link" | ||||
|           data-qa={"edit-#{tag.id}"} | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|         </.link> | ||||
|  | ||||
|         <.link | ||||
|           href="#" | ||||
|           class="text-primary-600 link" | ||||
|           phx-click="delete" | ||||
|           phx-value-id={tag.id} | ||||
|           data-confirm={ | ||||
|             dgettext("prompts", "Are you sure you want to delete %{name}?", name: tag.name) | ||||
|           } | ||||
|           data-qa={"delete-#{tag.id}"} | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         </.link> | ||||
|       </.tag_card> | ||||
|     </div> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= if @live_action in [:new, :edit] do %> | ||||
|   <.modal return_to={Routes.tag_index_path(Endpoint, :index)}> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.TagLive.FormComponent} | ||||
|       id={@tag.id || :new} | ||||
|       title={@page_title} | ||||
|       action={@live_action} | ||||
|       tag={@tag} | ||||
|       return_to={Routes.tag_index_path(Endpoint, :index)} | ||||
|       current_user={@current_user} | ||||
|     /> | ||||
|   </.modal> | ||||
| <% end %> | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={Routes.tag_index_path(Endpoint, :index)}> | ||||
|   <.live_component | ||||
|     module={CanneryWeb.TagLive.FormComponent} | ||||
|     id={@tag.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     tag={@tag} | ||||
|     return_to={Routes.tag_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -11,15 +11,17 @@ defmodule CanneryWeb.Router do | ||||
|     plug :protect_from_forgery | ||||
|     plug :put_secure_browser_headers | ||||
|     plug :fetch_current_user | ||||
|     plug :put_user_locale, default: Application.get_env(:gettext, :default_locale, "en_US") | ||||
|     plug :put_user_locale | ||||
|   end | ||||
|  | ||||
|   defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, default: default) do | ||||
|   defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(locale || default) | ||||
|     conn |> put_session(:locale, locale || default) | ||||
|   end | ||||
|  | ||||
|   defp put_user_locale(conn, default: default) do | ||||
|   defp put_user_locale(conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(default) | ||||
|     conn |> put_session(:locale, default) | ||||
|   end | ||||
| @@ -60,42 +62,52 @@ defmodule CanneryWeb.Router do | ||||
|     put "/users/settings", UserSettingsController, :update | ||||
|     delete "/users/settings/:id", UserSettingsController, :delete | ||||
|     get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email | ||||
|     get "/export/:mode", ExportController, :export | ||||
|  | ||||
|     live "/tags", TagLive.Index, :index | ||||
|     live "/tags/new", TagLive.Index, :new | ||||
|     live "/tags/:id/edit", TagLive.Index, :edit | ||||
|     live "/tags/edit/:id", TagLive.Index, :edit | ||||
|     live "/tags/search/:search", TagLive.Index, :search | ||||
|  | ||||
|     live "/catalog", AmmoTypeLive.Index, :index | ||||
|     live "/catalog/new", AmmoTypeLive.Index, :new | ||||
|     live "/catalog/:id/edit", AmmoTypeLive.Index, :edit | ||||
|     live "/catalog/clone/:id", AmmoTypeLive.Index, :clone | ||||
|     live "/catalog/edit/:id", AmmoTypeLive.Index, :edit | ||||
|     live "/catalog/search/:search", AmmoTypeLive.Index, :search | ||||
|  | ||||
|     live "/catalog/:id", AmmoTypeLive.Show, :show | ||||
|     live "/catalog/:id/show/edit", AmmoTypeLive.Show, :edit | ||||
|     live "/type/:id", AmmoTypeLive.Show, :show | ||||
|     live "/type/:id/edit", AmmoTypeLive.Show, :edit | ||||
|     live "/type/:id/table", AmmoTypeLive.Show, :table | ||||
|  | ||||
|     live "/containers", ContainerLive.Index, :index | ||||
|     live "/containers/new", ContainerLive.Index, :new | ||||
|     live "/containers/:id/edit", ContainerLive.Index, :edit | ||||
|     live "/containers/:id/edit_tags", ContainerLive.Index, :edit_tags | ||||
|     live "/containers/edit/:id", ContainerLive.Index, :edit | ||||
|     live "/containers/clone/:id", ContainerLive.Index, :clone | ||||
|     live "/containers/edit_tags/:id", ContainerLive.Index, :edit_tags | ||||
|     live "/containers/search/:search", ContainerLive.Index, :search | ||||
|  | ||||
|     live "/containers/:id", ContainerLive.Show, :show | ||||
|     live "/containers/:id/show/edit", ContainerLive.Show, :edit | ||||
|     live "/containers/:id/show/edit_tags", ContainerLive.Show, :edit_tags | ||||
|     live "/container/:id", ContainerLive.Show, :show | ||||
|     live "/container/edit/:id", ContainerLive.Show, :edit | ||||
|     live "/container/edit_tags/:id", ContainerLive.Show, :edit_tags | ||||
|  | ||||
|     live "/ammo", AmmoGroupLive.Index, :index | ||||
|     live "/ammo/new", AmmoGroupLive.Index, :new | ||||
|     live "/ammo/:id/edit", AmmoGroupLive.Index, :edit | ||||
|     live "/ammo/:id/add_shot_group", AmmoGroupLive.Index, :add_shot_group | ||||
|     live "/ammo/:id/move", AmmoGroupLive.Index, :move | ||||
|     live "/ammo/edit/:id", AmmoGroupLive.Index, :edit | ||||
|     live "/ammo/clone/:id", AmmoGroupLive.Index, :clone | ||||
|     live "/ammo/add_shot_group/:id", AmmoGroupLive.Index, :add_shot_group | ||||
|     live "/ammo/move/:id", AmmoGroupLive.Index, :move | ||||
|     live "/ammo/search/:search", AmmoGroupLive.Index, :search | ||||
|  | ||||
|     live "/ammo/:id", AmmoGroupLive.Show, :show | ||||
|     live "/ammo/:id/show/edit", AmmoGroupLive.Show, :edit | ||||
|     live "/ammo/:id/show/add_shot_group", AmmoGroupLive.Show, :add_shot_group | ||||
|     live "/ammo/:id/show/move", AmmoGroupLive.Show, :move | ||||
|     live "/ammo/:id/show/:shot_group_id/edit", AmmoGroupLive.Show, :edit_shot_group | ||||
|     live "/ammo/show/:id", AmmoGroupLive.Show, :show | ||||
|     live "/ammo/show/edit/:id", AmmoGroupLive.Show, :edit | ||||
|     live "/ammo/show/add_shot_group/:id", AmmoGroupLive.Show, :add_shot_group | ||||
|     live "/ammo/show/move/:id", AmmoGroupLive.Show, :move | ||||
|     live "/ammo/show/:id/edit/:shot_group_id", AmmoGroupLive.Show, :edit_shot_group | ||||
|  | ||||
|     live "/range", RangeLive.Index, :index | ||||
|     live "/range/:id/edit", RangeLive.Index, :edit | ||||
|     live "/range/:id/add_shot_group", RangeLive.Index, :add_shot_group | ||||
|     live "/range/edit/:id", RangeLive.Index, :edit | ||||
|     live "/range/add_shot_group/:id", RangeLive.Index, :add_shot_group | ||||
|     live "/range/search/:search", RangeLive.Index, :search | ||||
|   end | ||||
|  | ||||
|   scope "/", CanneryWeb do | ||||
|   | ||||
| @@ -57,6 +57,30 @@ defmodule CanneryWeb.Telemetry do | ||||
|           "The time the connection spent waiting before being checked out for the query" | ||||
|       ), | ||||
|  | ||||
|       # Oban Metrics | ||||
|       counter("oban.job.exception", | ||||
|         tags: [:queue, :worker], | ||||
|         event_name: [:oban, :job, :exception], | ||||
|         measurement: :duration, | ||||
|         description: "Number of oban jobs that raised an exception" | ||||
|       ), | ||||
|       counter("oban.job.start", | ||||
|         tags: [:queue, :worker], | ||||
|         event_name: [:oban, :job, :start], | ||||
|         measurement: :system_time, | ||||
|         description: "Number of oban jobs started" | ||||
|       ), | ||||
|       summary("oban.job.stop.duration", | ||||
|         tags: [:queue, :worker], | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "Length of time spent processing the oban job" | ||||
|       ), | ||||
|       summary("oban.job.stop.queue_time", | ||||
|         tags: [:queue, :worker], | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "Time the oban job spent waiting in milliseconds" | ||||
|       ), | ||||
|  | ||||
|       # VM Metrics | ||||
|       summary("vm.memory.total", unit: {:byte, :kilobyte}), | ||||
|       summary("vm.total_run_queue_lengths.total"), | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title> | ||||
|       <%= dgettext("errors", "Error") %>| Cannery | ||||
|       <%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %> | ||||
|     </title> | ||||
|     <link rel="stylesheet" href="/css/app.css" /> | ||||
|     <script defer type="text/javascript" src="/js/app.js"> | ||||
|   | ||||
| @@ -3,16 +3,12 @@ | ||||
|     <.topbar current_user={assigns[:current_user]}></.topbar> | ||||
|  | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <%= if get_flash(@conn, :info) do %> | ||||
|         <p class="alert alert-info" role="alert"> | ||||
|           <%= get_flash(@conn, :info) %> | ||||
|         </p> | ||||
|       <% end %> | ||||
|       <%= if get_flash(@conn, :error) do %> | ||||
|         <p class="alert alert-danger" role="alert"> | ||||
|           <%= get_flash(@conn, :error) %> | ||||
|         </p> | ||||
|       <% end %> | ||||
|       <p :if={get_flash(@conn, :info)} class="alert alert-info" role="alert"> | ||||
|         <%= get_flash(@conn, :info) %> | ||||
|       </p> | ||||
|       <p :if={get_flash(@conn, :error)} class="alert alert-danger" role="alert"> | ||||
|         <%= get_flash(@conn, :error) %> | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
|  | ||||
|   | ||||
| @@ -1,54 +1,45 @@ | ||||
| <main class="mb-8 min-w-full"> | ||||
| <main class="pb-8 min-w-full"> | ||||
|   <header> | ||||
|     <.topbar current_user={assigns[:current_user]}></.topbar> | ||||
|  | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <%= if @flash && @flash |> Map.has_key?("info") do %> | ||||
|         <p class="alert alert-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info"> | ||||
|           <%= live_flash(@flash, "info") %> | ||||
|         </p> | ||||
|       <% end %> | ||||
|       <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> | ||||
|  | ||||
|       <%= if @flash && @flash |> Map.has_key?("error") do %> | ||||
|         <p | ||||
|           class="alert alert-danger" | ||||
|           role="alert" | ||||
|           phx-click="lv:clear-flash" | ||||
|           phx-value-key="error" | ||||
|         > | ||||
|           <%= live_flash(@flash, "error") %> | ||||
|         </p> | ||||
|       <% end %> | ||||
|       <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"> | ||||
|   <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> | ||||
|     <%= @inner_content %> | ||||
|   </div> | ||||
| </main> | ||||
|  | ||||
| <div | ||||
|   id="loading" | ||||
|   class="fixed opacity-0 top-0 left-0 w-screen h-screen bg-white z-50 | ||||
|   flex flex-col justify-center items-center space-y-4 | ||||
|   transition-opacity ease-in-out duration-500" | ||||
| > | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= gettext("Loading...") %> | ||||
|   </h1> | ||||
|  | ||||
|   <i class="fas fa-3x fa-spin fa-gear"></i> | ||||
| </div> | ||||
|  | ||||
| <div | ||||
|   id="disconnect" | ||||
|   class="fixed opacity-0 top-0 left-0 w-screen h-screen bg-white z-50 | ||||
|   flex flex-col justify-center items-center space-y-4 | ||||
|   transition-opacity ease-in-out duration-500" | ||||
|   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]" | ||||
| > | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|   <i class="fas fa-fade text-md fa-satellite-dish"></i> | ||||
|  | ||||
|   <h1 class="title text-md title-primary-500"> | ||||
|     <%= gettext("Reconnecting...") %> | ||||
|   </h1> | ||||
|  | ||||
|   <i class="fas fa-3x fa-fade fa-satellite-dish"></i> | ||||
| </div> | ||||
|   | ||||
| @@ -5,8 +5,14 @@ | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <%= csrf_meta_tag() %> | ||||
|     <%= if(assigns |> Map.has_key?(:page_title), do: @page_title, else: "Cannery") | ||||
|     |> live_title_tag(suffix: " | Cannery") %> | ||||
|     <link | ||||
|       rel="shortcut icon" | ||||
|       type="image/jpg" | ||||
|       href={Routes.static_path(@conn, "/images/cannery.svg")} | ||||
|     /> | ||||
|     <.live_title suffix={" | #{gettext("Cannery")}"}> | ||||
|       <%= assigns[:page_title] || gettext("Cannery") %> | ||||
|     </.live_title> | ||||
|     <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} /> | ||||
|     <script | ||||
|       defer | ||||
| @@ -17,7 +23,7 @@ | ||||
|     </script> | ||||
|   </head> | ||||
|  | ||||
|   <body class="m-0 p-0 w-full h-full bg-white"> | ||||
|   <body class="m-0 p-0 w-full h-full subpixel-antialiased"> | ||||
|     <%= @inner_content %> | ||||
|   </body> | ||||
| </html> | ||||
|   | ||||
| @@ -1,35 +1,34 @@ | ||||
| <div class="mx-auto mb-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"> | ||||
|     <%= dgettext("actions", "Resend confirmation instructions") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= form_for :user, | ||||
|            Routes.user_confirmation_path(@conn, :create), | ||||
|            [ | ||||
|              class: | ||||
|                "flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|            ], | ||||
|            fn f -> %> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_confirmation_path(@conn, :create)} | ||||
|     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, class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Resend confirmation instructions"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   <% end %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <%= link(dgettext("actions", "Register"), | ||||
|         to: Routes.user_registration_path(@conn, :new), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|     <% end %> | ||||
|     <%= link(dgettext("actions", "Log in"), | ||||
|       to: Routes.user_session_path(@conn, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,25 +1,20 @@ | ||||
| <div class="mx-auto mb-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"> | ||||
|     <%= dgettext("actions", "Register") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= form_for @changeset, | ||||
|            Routes.user_registration_path(@conn, :create), | ||||
|            [ | ||||
|              class: | ||||
|                "flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|            ], | ||||
|            fn f -> %> | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="alert alert-danger col-span-3"> | ||||
|         <p> | ||||
|           <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|         </p> | ||||
|       </div> | ||||
|     <% end %> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_registration_path(@conn, :create)} | ||||
|     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"> | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </p> | ||||
|  | ||||
|     <%= if @invite do %> | ||||
|       <%= hidden_input(f, :invite_token, value: @invite.token) %> | ||||
|     <%= if @invite_token do %> | ||||
|       <%= hidden_input(f, :invite_token, value: @invite_token) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= label(f, :email, class: "title text-lg text-primary-600") %> | ||||
| @@ -34,24 +29,22 @@ | ||||
|     <%= select( | ||||
|       f, | ||||
|       :locale, | ||||
|       [{gettext("English"), "en_US"}, {gettext("German"), "de"}, {gettext("French"), "fr"}], | ||||
|       [{gettext("English"), "en_US"}], | ||||
|       class: "input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :locale) %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %> | ||||
|   <% end %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= link(dgettext("actions", "Log in"), | ||||
|       to: Routes.user_session_path(@conn, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <%= link(dgettext("actions", "Forgot your password?"), | ||||
|       to: Routes.user_reset_password_path(@conn, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,22 +1,19 @@ | ||||
| <div class="mx-auto mb-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"> | ||||
|     <%= dgettext("actions", "Reset password") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= form_for @changeset, | ||||
|            Routes.user_reset_password_path(@conn, :update, @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" | ||||
|            ], | ||||
|            fn f -> %> | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="alert alert-danger col-span-3"> | ||||
|         <p> | ||||
|           <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|         </p> | ||||
|       </div> | ||||
|     <% end %> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_reset_password_path(@conn, :update, @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" | ||||
|   > | ||||
|     <div :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
|       <p> | ||||
|         <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|       </p> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :password, "New password", class: "title text-lg text-primary-600") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
| @@ -34,20 +31,20 @@ | ||||
|     <%= submit(dgettext("actions", "Reset password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   <% end %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <%= link(dgettext("actions", "Register"), | ||||
|         to: Routes.user_registration_path(@conn, :new), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|     <% end %> | ||||
|     <%= link(dgettext("actions", "Log in"), | ||||
|       to: Routes.user_session_path(@conn, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,35 +1,34 @@ | ||||
| <div class="mx-auto mb-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"> | ||||
|     <%= dgettext("actions", "Forgot your password?") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= form_for :user, | ||||
|            Routes.user_reset_password_path(@conn, :create), | ||||
|            [ | ||||
|              class: | ||||
|                "flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|            ], | ||||
|            fn f -> %> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_reset_password_path(@conn, :create)} | ||||
|     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, class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Send instructions to reset password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   <% end %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <%= link(dgettext("actions", "Register"), | ||||
|         to: Routes.user_registration_path(@conn, :new), | ||||
|         class: "btn btn-primary" | ||||
|       ) %> | ||||
|     <% end %> | ||||
|     <%= link(dgettext("actions", "Log in"), | ||||
|       to: Routes.user_session_path(@conn, :new), | ||||
|       class: "btn btn-primary" | ||||
|     ) %> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user