158 Commits

Author SHA1 Message Date
8c95536ffd add selectable ammo types
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-23 22:07:25 -04:00
d9251c7e4c improve components 2023-03-23 00:21:26 -04:00
fe4e4f4f17 add length limits to all items
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-19 23:52:25 -04:00
e5e5449e8b improve modal accessibility
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-19 15:39:39 -04:00
355752598c show link to ammo pack in ammo pack table while viewing ammo type
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-19 15:09:44 -04:00
03f8a2e8a7 remove all n+1 queries for real this time 2023-03-19 15:05:09 -04:00
071eb1b3c9 fix some values not being sorted in tables properly 2023-03-19 14:31:53 -04:00
2987e4ff37 hide more ammo group table fields when not viewing historical information 2023-03-19 14:11:01 -04:00
ca81924ebe fix ammo type table not displaying correct information 2023-03-19 14:07:23 -04:00
40e4f6fe0a remove :table path 2023-03-19 13:37:28 -04:00
213dcca973 fix duplicate entries showing up 2023-03-19 13:28:56 -04:00
b32edd581d fix accessibility issues
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-19 12:35:26 -04:00
2e372ca2ab hide historical ammo type information until show_used is toggled 2023-03-19 11:43:13 -04:00
fd0bac3bbf fix tables unable to sort on nil dates 2023-03-19 11:19:55 -04:00
f83fbc5d99 add links to readme 2023-03-19 00:41:39 -04:00
daab051026 remove unnecessary auth check on invite page 2023-03-19 00:23:59 -04:00
440dc5061b fix textareas resizing when typing in
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-18 22:14:04 -04:00
c0d2c69144 run npm audit fix 2023-03-18 22:02:27 -04:00
7a7359fa66 run npx npm-check-updates -u 2023-03-18 22:01:07 -04:00
9e8fd00d65 add ncu as dev dependency 2023-03-18 21:56:21 -04:00
f5f72b53e6 use hooks for datetime, remove alpinejs 2023-03-18 21:54:57 -04:00
a54cf8b87d use strict context boundaries and remove all n+1 queries
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-18 21:06:50 -04:00
0b7146ba32 fix shot record error message 2023-03-18 01:05:09 -04:00
c0441957b6 use .link helpers 2023-03-18 01:03:55 -04:00
7fa9933a9b use core components 2023-03-18 00:25:18 -04:00
f4c7f22460 fix error message in ga locale
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-17 21:10:34 -04:00
a01d97e360 use better domain for gettexts
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-17 21:00:24 -04:00
a53b352cf7 Translated using Weblate (Irish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 100.0% (14 of 14 strings)

Translation: cannery/emails
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/emails/ga/
2023-03-18 00:52:59 +00:00
ce07cc2569 use live navigation to update state
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-16 17:53:49 -04:00
3acecb9a93 remove extra @impl true
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-16 17:41:25 -04:00
ab8561fcf0 use component macros for live_helper components
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-15 00:48:07 -04:00
8163b906a2 remove data-qa 2023-03-15 00:47:15 -04:00
b29a5cce7b bump version
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-10 16:58:49 -05:00
9205a04ac5 tweak invite and settings pages
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-26 18:21:14 -05:00
632a9e1379 remove extraneous aliases
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-26 14:22:54 -05:00
92cc49630d merge base project into cannery
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-26 00:40:55 -05:00
a778f5a61f rename to cannery 2023-02-25 16:02:08 -05:00
07ff796553 fix more gettexts
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-25 00:59:05 -05:00
07b31fcc86 remove doctests 2023-02-25 00:59:05 -05:00
bc034c0361 remove doctests 2023-02-25 00:58:33 -05:00
eb9280fa7e fix missing gettexts 2023-02-25 00:39:02 -05:00
ad1e44fd42 improve user settings 2023-02-14 01:06:50 -05:00
4e9f66f006 bump version 2023-02-14 01:06:00 -05:00
81350f9898 improve user settings page 2023-02-14 00:06:43 -05:00
9c4a32896f improve logger
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-07 00:18:16 -05:00
56dae6cdfe improve logger 2023-02-07 00:14:20 -05:00
8ef3bd65a3 remove search from topbar 2023-02-07 00:06:25 -05:00
c8cadd6246 remove phoenix logo 2023-02-07 00:03:10 -05:00
3f143262d4 improve ci
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-05 10:37:05 -05:00
ebe09bcf84 improve ci 2023-02-05 10:24:47 -05:00
42e2d1c76e fix tests
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-05 00:51:51 -05:00
064d2d3988 improve oban logging 2023-02-05 00:51:51 -05:00
fd0b2c455a fix tests 2023-02-04 21:50:43 -05:00
f1139d0ec4 improve oban logging 2023-02-04 20:47:47 -05:00
f6b5fc17fa fix padding on chrome 2023-02-04 20:41:29 -05:00
cd6bb6fbc3 fix padding on chrome 2023-02-04 20:40:46 -05:00
2c0a4dd7ca improve invites, record usage 2023-02-04 17:33:26 -05:00
5c05f3b6fe use credo style 2023-02-04 17:32:40 -05:00
30d3f76fe1 record invites 2023-02-04 16:11:58 -05:00
ed8c20e967 improve invites 2023-02-04 15:05:00 -05:00
47dab6490d improve templates 2023-02-04 13:01:50 -05:00
7b60938a75 improve templates 2023-02-04 13:01:11 -05:00
f19d024d8a fix bug with public registration being disabled 2023-02-04 11:35:32 -05:00
084173909e fix bug with public registration 2023-02-04 10:27:57 -05:00
1fbed50b0f add crypto to extra applications 2023-02-01 22:39:38 -05:00
2f8af8ae4f add crypto to extra applications 2023-02-01 22:39:16 -05:00
737484c36e add accounts doctests 2023-01-29 15:24:14 -05:00
2cf705c46f add accounts doctests 2023-01-29 15:23:54 -05:00
725df05521 fix aliases
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-29 13:05:01 -05:00
3ae890c193 use atoms for role changeset 2023-01-29 12:57:07 -05:00
6dbadc58ae fix runtime config 2023-01-26 19:55:59 -05:00
75fcbb1e65 fix runtime config
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-26 19:48:58 -05:00
e568a2f073 fix credo
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-26 18:11:56 -05:00
0b27c8f80b add qr code for invite link
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-26 00:46:25 -05:00
f155a43ee8 add qr code for invite link 2023-01-26 00:46:21 -05:00
0ad1ee47de improve formatting 2023-01-26 00:40:48 -05:00
8ea2b06487 use list for classes 2023-01-26 00:39:53 -05:00
bbaa1dfd6b update to liveview 0.18 2023-01-26 00:31:13 -05:00
2c2b9fefc9 use list for classes 2023-01-25 23:13:18 -05:00
bafc824a32 add subpixel antialiasing 2023-01-25 22:15:18 -05:00
8c2f7e0509 fix padding at bottom of page in chrome 2023-01-25 22:14:17 -05:00
1e4accec9d fix padding at bottom of page in chrome 2023-01-25 22:14:04 -05:00
076d5eea18 add styles to background element 2023-01-25 22:07:33 -05:00
5b6bd00047 improve locale 2023-01-25 21:52:30 -05:00
22abc7a8d0 improve locale 2023-01-25 21:50:14 -05:00
5a685ac00e use topbar through npm 2023-01-25 21:18:00 -05:00
8dd471afa8 use topbar through npm 2023-01-25 21:17:45 -05:00
a5c12b3e17 fix webpack 2023-01-25 21:14:55 -05:00
27af5acf8b minimize disconnection modal 2023-01-25 21:00:34 -05:00
469428c007 add topbar.js on form submit and page navigate 2023-01-25 20:31:36 -05:00
09d3754f92 remove name
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-23 00:05:15 -05:00
fa67fd5a3b add comment
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 23:47:28 -05:00
1cd28e43b8 update elixir and npm versions 2023-01-22 23:20:02 -05:00
695002c9d9 don't override core aliases 2023-01-22 22:41:42 -05:00
dde60d71d1 update gettext 2023-01-22 22:37:49 -05:00
91794ddc55 upgrade dependencies 2023-01-22 22:36:20 -05:00
1e3cec95fe update drone config 2023-01-07 21:00:15 -05:00
f0a8c515f9 update deps 2023-01-07 21:00:15 -05:00
e99775eef2 fix gettext merge command 2023-01-07 21:00:15 -05:00
6760f83ca0 add gettext changes 2023-01-07 21:00:15 -05:00
10877bb754 rename page path to home path 2023-01-07 21:00:15 -05:00
38a581b639 add registered on date to user card 2023-01-07 21:00:15 -05:00
9408705430 update deps 2023-01-07 21:00:14 -05:00
302aa7eeda add locale to user settings 2023-01-07 21:00:14 -05:00
fd4fdcc36b add table component 2023-01-07 21:00:14 -05:00
3cb723b9e4 update build settings 2023-01-07 21:00:14 -05:00
1f92c452d1 fix tests 2023-01-07 21:00:14 -05:00
c10cff63ea add invites route 2023-01-07 21:00:14 -05:00
70faed71d0 style disconnection and loading screens 2023-01-07 21:00:14 -05:00
b5c46c09ec fix oban 2023-01-07 21:00:14 -05:00
7745765fc0 update npm 2023-01-07 21:00:14 -05:00
e16fbba810 gettext page_live 2023-01-07 21:00:14 -05:00
e35bdf101b update components 2023-01-07 21:00:14 -05:00
aa314e5ca1 update main routes 2023-01-07 21:00:14 -05:00
616de3c117 update tests 2023-01-07 21:00:14 -05:00
74bcec6cfe update controllers and auth 2023-01-07 21:00:14 -05:00
41090c46d0 add invites 2023-01-07 21:00:14 -05:00
c3f5744ad6 fix layout 2023-01-07 21:00:14 -05:00
95a339fe02 add random script 2023-01-07 21:00:14 -05:00
1e3b027367 fix docker files 2023-01-07 21:00:14 -05:00
dd46e1795f add credo 2023-01-07 21:00:14 -05:00
9e517e6477 add emails 2023-01-07 21:00:14 -05:00
34118299e9 add exdoc 2022-02-25 21:13:50 -05:00
97a9b6d51a prevent unconfirmed users from logging in 2022-02-16 22:16:19 -05:00
059004ba78 mix format 2022-02-16 22:15:47 -05:00
5d02ed6369 update deps 2022-02-16 22:15:16 -05:00
ec6acdbb5d update swoosh 2022-02-15 19:12:35 -05:00
33d82a902d add exdoc configs 2022-02-15 18:57:06 -05:00
fc5b03d680 enable live dashboard os monitoring 2022-02-15 18:57:01 -05:00
c918dbe4bf improve changeset errors 2022-02-15 18:22:58 -05:00
23b60e032d add datetime helper 2022-02-15 02:50:36 -05:00
7283932d85 use topbar component 2022-02-13 21:28:20 -05:00
8ff1fd0276 add gettext step to docker file 2022-02-13 15:50:48 -05:00
4ef09f5279 heex templates 2022-02-12 23:26:29 -05:00
9734be4966 add oban 2022-02-12 23:24:11 -05:00
485965d9c9 npm audit 2022-02-12 23:23:09 -05:00
e9cdb0f717 add dockerhub build and publish step 2022-02-12 02:38:29 -05:00
1c07449b54 add alpine.js 2022-02-12 02:37:10 -05:00
b64e85f65c add drone ci 2022-01-23 00:27:43 -05:00
9387756109 mix format 2022-01-23 00:00:53 -05:00
f1f3082368 add MaintainAttrs hook 2022-01-22 23:59:58 -05:00
50a8a79596 update readme and docker-compose.yml 2022-01-22 23:44:47 -05:00
67b48e1a3f format with standard js 2022-01-22 22:56:53 -05:00
67c30d7f88 add standard js 2022-01-22 22:56:38 -05:00
728728a5a4 fix tests 2022-01-22 20:44:38 -05:00
a64d92a6cf add dialyzer and credo to tests 2022-01-22 20:44:34 -05:00
6227d64072 set generator settings 2022-01-22 20:13:53 -05:00
46eed25a94 only start automigrator in prod env 2022-01-22 20:12:32 -05:00
3674eeaf5a fix tests 2022-01-22 15:24:04 -05:00
f0676a2433 add mix format to tests 2022-01-22 15:24:04 -05:00
a72a4b0cbe add heex_formatter 2022-01-22 15:24:04 -05:00
a2dea04668 update to 1.6 2022-01-22 14:25:37 -05:00
3dc255b7c2 add touchless docker deploys 2021-09-04 16:19:23 -04:00
bde1cff7a4 add font awesome 2021-09-02 23:32:53 -04:00
66cc11e9eb add liveview helpers 2021-09-02 23:32:53 -04:00
81a250206e update npm 2021-09-02 23:32:52 -04:00
6d5f7f68df style registration pages 2021-09-02 23:32:52 -04:00
5b5f1ce1e5 run phx.new and add phx.gen.auth 2021-08-15 19:11:09 -04:00
184 changed files with 20796 additions and 8948 deletions

View File

@ -13,20 +13,24 @@ steps:
mount: mount:
- _build - _build
- deps - deps
- assets/node_modules/ - .npm
- .mix
- name: test - name: test
image: elixir:1.14.1-alpine image: elixir:1.14.1-alpine
environment: environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld HOST: testing.example.tld
MIX_HOME: /drone/src/.mix
MIX_ARCHIVES: /drone/src/.mix/archives
MIX_ENV: test
commands: commands:
- apk add --no-cache build-base npm git python3 - apk add --no-cache build-base npm git
- mix local.rebar --force - mix local.rebar --force --if-missing
- mix local.hex --force - mix local.hex --force --if-missing
- mix deps.get - mix deps.get
- mix deps.compile - npm set cache .npm
- npm --prefix ./assets ci --progress=false --no-audit --loglevel=error - npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy - npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract - mix do phx.digest, gettext.extract
- mix test.all - mix test.all
@ -76,7 +80,8 @@ steps:
mount: mount:
- _build - _build
- deps - deps
- assets/node_modules/ - .npm
- .mix
services: services:
- name: database - name: database

2
.gitignore vendored
View File

@ -25,7 +25,7 @@ cannery-*.tar
# If NPM crashes, it generates a log, let's ignore it too. # If NPM crashes, it generates a log, let's ignore it too.
npm-debug.log npm-debug.log
# Ignore assets that are produced by build tools. # The directory NPM downloads your dependencies sources to.
/assets/node_modules/ /assets/node_modules/
# Since we are building assets from assets/, # Since we are building assets from assets/,

View File

@ -1,3 +1,3 @@
elixir 1.14.1-otp-25 elixir 1.14.1-otp-25
erlang 25.1.2 erlang 25.1.2
nodejs 18.12.1 nodejs 18.9.1

View File

@ -1,7 +1,52 @@
# v0.9.0
- Add length limits to all string fields
- Add selectable ammo types
- Improve onboarding experience slightly
- Remove show used view from a container since it doesn't really make that much
sense
# v0.8.6
- Fix duplicate entries showing up
- Show ammo packs under a type in a table by default
- Only show historical ammo type information when displaying "Show used" in table
- Only show historical ammo pack information when displaying "Show used" in table
- Fix some values not being sorted in tables properly
- Code quality improvements
- Show link to ammo pack in ammo pack table while viewing ammo type
# v0.8.5
- Add link in readme to github mirror
- Fix tables unable to sort on empty dates
- Only show historical ammo type information when displaying "Show used"
- Fix even more accessibility issues
# v0.8.4
- Improve accessibility
- Code quality improvements
- Fix dead link of example bullet abbreviations
- Fix inaccurate error message when updating shot records
- Fix tables not sorting dates correctly
- Fix dates displaying incorrectly
- Fix container table not displaying all fields
- Fix textareas resizing when typing in them
# v0.8.3
- Improve some styles
- Improve server log
- Various minor improvements
# v0.8.2
- Fix bug with public registration
- Improve templates
- Improve invites, record usage
- Fix padding on more pages when using chrome
- Add oban metrics to server log and live dashboard
# v0.8.1 # v0.8.1
- Update dependencies - Update dependencies
- Show topbar on form submit/page refresh - Show topbar on form submit/page refresh
- Make loading/reconnection less intrusive - Make loading/reconnection less intrusive
- Add QR code for invite link
# v0.8.0 # v0.8.0
- Add search to catalog, ammo, container, tag and range index pages - Add search to catalog, ammo, container, tag and range index pages

View File

@ -63,7 +63,8 @@ And as always, thank you!
[`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/). [`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/).
- `Dockerfile` and example `docker-compose.yml` - `Dockerfile` and example `docker-compose.yml`
- Automatic migrations in `MIX_ENV=prod` or Docker image - Automatic migrations in `MIX_ENV=prod` or Docker image
- JS linting with [standard.js](https://standardjs.com) - JS linting with [standard.js](https://standardjs.com), HEEx linting with
[heex_formatter](https://github.com/feliperenan/heex_formatter)
## Docs ## Docs
@ -109,7 +110,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime.
- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US`. - `LOCALE`: Sets a custom default locale. Defaults to `en_US`.
- Available options: `en_US`, `de`, and `fr` - Available options: `en_US`, `de`, `fr`, and `es`
## `MIX_ENV=test` ## `MIX_ENV=test`

View File

@ -64,7 +64,7 @@ You can use the following environment variables to configure Cannery in
- `REGISTRATION`: Controls if user sign-up should be invite only or set to - `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`. public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US` - `LOCALE`: Sets a custom default locale. Defaults to `en_US`
- Available options: `en_US`, `de`, and `fr` - Available options: `en_US`, `de`, `fr` and `es`
- `SMTP_HOST`: The url for your SMTP email provider. Must be set - `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! - `SMTP_USERNAME`: The username for your SMTP relay. Must be set!
@ -92,6 +92,15 @@ Cannery is licensed under AGPLv3 or later. A copy of the latest version of the
license can be found at license can be found at
[LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md). [LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md).
# Links
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please
don't open pull requests to this repository
- [Weblate](https://weblate.bubbletea.dev/engage/cannery): Contribute to
translations!
--- ---
[![Build [![Build

View File

@ -25,6 +25,7 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
100% { scale: 1.0; opacity: 1; } 100% { scale: 1.0; opacity: 1; }
} }
// disconnect toast
.phx-connected > #disconnect { .phx-connected > #disconnect {
opacity: 0 !important; opacity: 0 !important;
pointer-events: none; pointer-events: none;

View File

@ -25,7 +25,6 @@
} }
.btn { .btn {
@apply inline-block break-words;
@apply focus:outline-none px-4 py-2 rounded-lg; @apply focus:outline-none px-4 py-2 rounded-lg;
@apply shadow-sm focus:shadow-lg; @apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out; @apply transition-all duration-300 ease-in-out;
@ -52,7 +51,6 @@
} }
.link { .link {
@apply inline-block break-words;
@apply hover:underline; @apply hover:underline;
@apply transition-colors duration-500 ease-in-out; @apply transition-colors duration-500 ease-in-out;
} }

View File

@ -24,26 +24,18 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration. // Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import topbar from '../vendor/topbar' import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs' import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart' import ShotLogChart from './shot_log_chart'
import Alpine from 'alpinejs' import Date from './date'
import DateTime from './datetime'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, { const liveSocket = new LiveSocket('/live', Socket, {
dom: {
onBeforeElUpdated (from, to) {
if (from._x_dataStack) { window.Alpine.clone(from, to) }
}
},
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },
hooks: { MaintainAttrs, ShotLogChart } hooks: { Date, DateTime, MaintainAttrs, ShotLogChart }
}) })
// alpine.js
window.Alpine = Alpine
Alpine.start()
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', info => topbar.show()) window.addEventListener('phx:page-loading-start', info => topbar.show())

11
assets/js/date.js Normal file
View File

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

11
assets/js/datetime.js Normal file
View File

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

9279
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@
"description": " ", "description": " ",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "18.12.1", "node": "v18.9.1",
"npm": "8.19.2" "npm": "8.19.1"
}, },
"scripts": { "scripts": {
"deploy": "NODE_ENV=production webpack --mode production", "deploy": "NODE_ENV=production webpack --mode production",
@ -13,37 +13,37 @@
"test": "standard" "test": "standard"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1", "@fortawesome/fontawesome-free": "^6.3.0",
"alpinejs": "^3.10.2", "chart.js": "^4.2.1",
"chart.js": "^3.9.1", "chartjs-adapter-date-fns": "^3.0.0",
"chartjs-adapter-date-fns": "^2.0.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"phoenix": "file:../deps/phoenix", "phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html", "phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view", "phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.0.1" "topbar": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.10", "@babel/core": "^7.21.3",
"@babel/preset-env": "^7.17.10", "@babel/preset-env": "^7.20.2",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.14",
"babel-loader": "^8.2.5", "babel-loader": "^9.1.2",
"copy-webpack-plugin": "^10.2.4", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.1", "css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^3.4.1", "css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.6.0", "mini-css-extract-plugin": "^2.7.5",
"postcss": "^8.4.13", "npm-check-updates": "^16.7.12",
"postcss-import": "^14.1.0", "postcss": "^8.4.21",
"postcss-loader": "^6.2.1", "postcss-import": "^15.1.0",
"postcss-preset-env": "^7.5.0", "postcss-loader": "^7.1.0",
"sass": "^1.56.0", "postcss-preset-env": "^8.0.1",
"sass-loader": "^12.6.0", "sass": "^1.59.3",
"sass-loader": "^13.2.1",
"standard": "^17.0.0", "standard": "^17.0.0",
"tailwindcss": "^3.0.24", "tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.1", "terser-webpack-plugin": "^5.3.7",
"webpack": "^5.72.0", "webpack": "^5.76.2",
"webpack-cli": "^4.9.2", "webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.9.0" "webpack-dev-server": "^4.13.1"
} }
} }

View File

@ -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));

View File

@ -11,6 +11,8 @@ config :cannery,
ecto_repos: [Cannery.Repo], ecto_repos: [Cannery.Repo],
generators: [binary_id: true] generators: [binary_id: true]
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint # Configures the endpoint
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
@ -18,8 +20,7 @@ config :cannery, CanneryWeb.Endpoint,
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false], render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false],
pubsub_server: Cannery.PubSub, pubsub_server: Cannery.PubSub,
live_view: [signing_salt: "zOLgd3lr"], live_view: [signing_salt: "zOLgd3lr"]
registration: System.get_env("REGISTRATION") || "invite"
config :cannery, Cannery.Application, automigrate: false config :cannery, Cannery.Application, automigrate: false

View File

@ -64,8 +64,9 @@ config :cannery, CanneryWeb.Endpoint,
] ]
] ]
# Do not include metadata nor timestamps in development logs config :logger, :console,
config :logger, :console, format: "[$level] $message\n" format: "[$level] $message $metadata\n\n",
metadata: [:data]
# Set a higher stacktrace during development. Avoid configuring such # Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive. # in production as building large stacktraces may be expensive.

View File

@ -15,17 +15,18 @@ end
config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
# Set default locale # Set default locale
config :gettext, :default_locale, System.get_env("LOCALE") || "en_US" config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: []
database_url = database_url =
if config_env() == :test do if config_env() == :test do
System.get_env("TEST_DATABASE_URL") || System.get_env(
"TEST_DATABASE_URL",
"ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}" "ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}"
)
else else
System.get_env("DATABASE_URL") || System.get_env("DATABASE_URL", "ecto://postgres:postgres@cannery-db/cannery")
"ecto://postgres:postgres@cannery-db/cannery"
end end
host = host =
@ -40,7 +41,7 @@ interface =
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
# ssl: true, # ssl: true,
url: database_url, url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")),
socket_options: maybe_ipv6 socket_options: maybe_ipv6
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
@ -49,10 +50,13 @@ config :cannery, CanneryWeb.Endpoint,
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses. # for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: interface, ip: interface,
port: String.to_integer(System.get_env("PORT") || "4000") port: String.to_integer(System.get_env("PORT", "4000"))
], ],
server: true, server: true
registration: System.get_env("REGISTRATION") || "invite"
if config_env() in [:dev, :prod] do
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
end
if config_env() == :prod do if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
@ -76,12 +80,12 @@ if config_env() == :prod do
config :cannery, Cannery.Mailer, config :cannery, Cannery.Mailer,
adapter: Swoosh.Adapters.SMTP, adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"),
port: System.get_env("SMTP_PORT") || 587, port: System.get_env("SMTP_PORT", "587"),
username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"),
password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"),
ssl: System.get_env("SMTP_SSL") == "true", ssl: System.get_env("SMTP_SSL") == "true",
email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}", email_from: System.get_env("EMAIL_FROM", "no-reply@#{System.get_env("HOST")}"),
email_name: System.get_env("EMAIL_NAME") || "Cannery" email_name: System.get_env("EMAIL_NAME", "Cannery")
# ## Using releases # ## Using releases
# #

View File

@ -22,6 +22,9 @@ config :cannery, CanneryWeb.Endpoint,
# In test we don't send emails. # In test we don't send emails.
config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
# Don't require invites for signups
config :cannery, Cannery.Accounts, registration: "public"
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warn config :logger, level: :warn

View File

@ -5,7 +5,7 @@ defmodule Cannery.Accounts do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Cannery.{Mailer, Repo} alias Cannery.{Mailer, Repo}
alias Cannery.Accounts.{User, UserToken} alias Cannery.Accounts.{Invite, Invites, User, UserToken}
alias Ecto.{Changeset, Multi} alias Ecto.{Changeset, Multi}
alias Oban.Job alias Oban.Job
@ -24,14 +24,16 @@ defmodule Cannery.Accounts do
""" """
@spec get_user_by_email(email :: String.t()) :: User.t() | nil @spec get_user_by_email(email :: String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """ @doc """
Gets a user by email and password. Gets a user by email and password.
## Examples ## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password") iex> get_user_by_email_and_password("foo@example.com", "valid_password")
%User{} %User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password") iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
@ -53,28 +55,30 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> get_user!(123) iex> get_user!(user_id)
%User{} user
iex> get_user!(456) iex> get_user!()
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_user!(User.t()) :: User.t() @spec get_user!(User.id()) :: User.t()
def get_user!(id), do: Repo.get!(User, id) def get_user!(id) do
Repo.get!(User, id)
end
@doc """ @doc """
Returns all users grouped by role. Returns all users grouped by role.
## Examples ## Examples
iex> list_users_by_role(%User{id: 123, role: :admin}) iex> list_all_users_by_role(user1)
[admin: [%User{}], user: [%User{}, %User{}]] %{admin: [%User{role: :admin}], user: [%User{role: :user}]}
""" """
@spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} @spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]}
def list_all_users_by_role(%User{role: :admin}) do def list_all_users_by_role(%User{role: :admin}) do
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end)
end end
@doc """ @doc """
@ -82,13 +86,12 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> list_users_by_role(%User{id: 123, role: :admin}) iex> list_users_by_role(:admin)
[%User{}] [%User{role: :admin}]
""" """
@spec list_users_by_role(User.role()) :: [User.t()] @spec list_users_by_role(:admin) :: [User.t()]
def list_users_by_role(role) do def list_users_by_role(:admin = role) do
role = role |> to_string()
Repo.all(from u in User, where: u.role == ^role, order_by: u.email) Repo.all(from u in User, where: u.role == ^role, order_by: u.email)
end end
@ -99,26 +102,36 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> register_user(%{field: value}) iex> register_user(%{email: "foo@example.com", password: "valid_password"})
{:ok, %User{}} {:ok, %User{email: "foo@example.com"}}
iex> register_user(%{field: bad_value}) iex> register_user(%{email: "foo@example"})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@spec register_user(attrs :: map()) :: {:ok, User.t()} | {:error, User.changeset()} @spec register_user(attrs :: map()) ::
def register_user(attrs) do {:ok, User.t()} | {:error, :invalid_token | User.changeset()}
@spec register_user(attrs :: map(), Invite.token() | nil) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
def register_user(attrs, invite_token \\ nil) do
Multi.new() Multi.new()
|> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true)) |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
|> Multi.insert(:add_user, fn %{users_count: count} -> |> Multi.run(:use_invite, fn _changes_so_far, _repo ->
if allow_registration?() and invite_token |> is_nil() do
{:ok, nil}
else
Invites.use_invite(invite_token)
end
end)
|> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} ->
# if no registered users, make first user an admin # if no registered users, make first user an admin
role = if count == 0, do: "admin", else: "user" role = if count == 0, do: :admin, else: :user
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
User.registration_changeset(attrs) |> User.role_changeset(role)
end) end)
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{add_user: user}} -> {:ok, user} {: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} {:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
end end
end end
@ -128,14 +141,18 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_registration(user) iex> change_user_registration()
%Changeset{data: %User{}} %Changeset{}
iex> change_user_registration(%{password: "hi"}
%Changeset{}
""" """
@spec change_user_registration() :: User.changeset() @spec change_user_registration() :: User.changeset()
@spec change_user_registration(attrs :: map()) :: User.changeset() @spec change_user_registration(attrs :: map()) :: User.changeset()
def change_user_registration(attrs \\ %{}), def change_user_registration(attrs \\ %{}) do
do: User.registration_changeset(attrs, hash_password: false) User.registration_changeset(attrs, nil, hash_password: false)
end
## Settings ## Settings
@ -144,25 +161,29 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_email(user) iex> change_user_email(%User{email: "foo@example.com"})
%Changeset{data: %User{}} %Changeset{}
""" """
@spec change_user_email(User.t()) :: User.changeset() @spec change_user_email(User.t()) :: User.changeset()
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset() @spec change_user_email(User.t(), attrs :: map()) :: User.changeset()
def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs)
end
@doc """ @doc """
Returns an `%Changeset{}` for changing the user role. Returns an `%Changeset{}` for changing the user role.
## Examples ## Examples
iex> change_user_role(user) iex> change_user_role(%User{}, :user)
%Changeset{data: %User{}} %Changeset{}
""" """
@spec change_user_role(User.t(), User.role()) :: User.changeset() @spec change_user_role(User.t(), User.role()) :: User.changeset()
def change_user_role(user, role), do: User.role_changeset(user, role) def change_user_role(user, role) do
User.role_changeset(user, role)
end
@doc """ @doc """
Emulates that the email will change without actually changing Emulates that the email will change without actually changing
@ -170,10 +191,10 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> apply_user_email(user, "valid password", %{email: ...}) iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"})
{:ok, %User{}} {:ok, %User{}}
iex> apply_user_email(user, "invalid password", %{email: ...}) iex> apply_user_email(user, "invalid password", %{email: "new_email@account"})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@ -219,8 +240,8 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end)
{:ok, %{to: ..., body: ...}} %Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}}
""" """
@spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: @spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) ::
@ -237,23 +258,27 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_password(user) iex> change_user_password(%User{})
%Changeset{data: %User{}} %Changeset{}
""" """
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset() @spec change_user_password(User.t(), attrs :: map()) :: User.changeset()
def change_user_password(user, attrs \\ %{}), def change_user_password(user, attrs \\ %{}) do
do: User.password_changeset(user, attrs, hash_password: false) User.password_changeset(user, attrs, hash_password: false)
end
@doc """ @doc """
Updates the user password. Updates the user password.
## Examples ## Examples
iex> update_user_password(user, "valid password", %{password: ...}) iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}} {:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...}) iex> update_user_password(user, "invalid password", %{password: "123"})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@ -276,49 +301,54 @@ defmodule Cannery.Accounts do
end end
@doc """ @doc """
Returns an `%Changeset{}` for changing the user locale. Returns an `Ecto.Changeset.t()` for changing the user locale.
## Examples ## Examples
iex> change_user_locale(user) iex> change_user_locale(%User{})
%Changeset{data: %User{}} %Changeset{}
""" """
@spec change_user_locale(User.t()) :: User.changeset() @spec change_user_locale(User.t()) :: User.changeset()
def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) def change_user_locale(%{locale: locale} = user) do
User.locale_changeset(user, locale)
end
@doc """ @doc """
Updates the user locale. Updates the user locale.
## Examples ## Examples
iex> update_user_locale(user, "valid locale") iex> update_user_locale(user, "en_US")
{:ok, %User{}} {:ok, %User{}}
iex> update_user_password(user, "invalid locale")
{:error, %Changeset{}}
""" """
@spec update_user_locale(User.t(), locale :: String.t()) :: @spec update_user_locale(User.t(), locale :: String.t()) ::
{:ok, User.t()} | {:error, User.changeset()} {:ok, User.t()} | {:error, User.changeset()}
def update_user_locale(user, locale), def update_user_locale(user, locale) do
do: user |> User.locale_changeset(locale) |> Repo.update() user |> User.locale_changeset(locale) |> Repo.update()
end
@doc """ @doc """
Deletes a user. must be performed by an admin or the same user! Deletes a user. must be performed by an admin or the same user!
## Examples ## Examples
iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) iex> delete_user!(user, %User{id: 123, role: :admin})
%User{} %User{}
iex> delete_user!(%User{id: 123}, %User{id: 123}) iex> delete_user!(user, user)
%User{} %User{}
""" """
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() @spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!() def delete_user!(user, %User{role: :admin}) do
def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!() user |> Repo.delete!()
end
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
user |> Repo.delete!()
end
## Session ## Session
@ -346,7 +376,7 @@ defmodule Cannery.Accounts do
""" """
@spec delete_session_token(token :: String.t()) :: :ok @spec delete_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do def delete_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session")) UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok :ok
end end
@ -355,19 +385,53 @@ defmodule Cannery.Accounts do
""" """
@spec allow_registration?() :: boolean() @spec allow_registration?() :: boolean()
def allow_registration? do def allow_registration? do
Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?()
list_users_by_role(:admin) |> Enum.empty?() end
@doc """
Returns an atom representing the current configured registration mode
"""
@spec registration_mode() :: :public | :invite_only
def registration_mode do
case Application.get_env(:cannery, Cannery.Accounts)[:registration] do
"public" -> :public
_other -> :invite_only
end
end end
@doc """ @doc """
Checks if user is an admin Checks if user is an admin
## Examples
iex> is_admin?(%User{role: :admin})
true
iex> is_admin?(%User{})
false
""" """
@spec is_admin?(User.t()) :: boolean() @spec is_admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do def is_admin?(%User{id: user_id}) do
Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin) Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
|> is_nil()
end end
@doc """
Checks to see if user has the admin role
## Examples
iex> is_already_admin?(%User{role: :admin})
true
iex> is_already_admin?(%User{})
false
"""
@spec is_already_admin?(User.t() | nil) :: boolean()
def is_already_admin?(%User{role: :admin}), do: true
def is_already_admin?(_invalid_user), do: false
## Confirmation ## Confirmation
@doc """ @doc """
@ -375,10 +439,10 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
{:ok, %{to: ..., body: ...}} %Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}}
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
{:error, :already_confirmed} {:error, :already_confirmed}
""" """
@ -425,8 +489,8 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end)
{:ok, %{to: ..., body: ...}} %Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}}
""" """
@spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t()
@ -442,7 +506,7 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> get_user_by_reset_password_token("validtoken") iex> get_user_by_reset_password_token(encoded_token)
%User{} %User{}
iex> get_user_by_reset_password_token("invalidtoken") iex> get_user_by_reset_password_token("invalidtoken")
@ -464,7 +528,10 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}} {:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})

View File

@ -27,21 +27,21 @@ defmodule Cannery.Email do
@spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t()
def generate_email("welcome", user, %{"url" => url}) do def generate_email("welcome", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Confirm your %{name} account", name: "Cannery")) |> base_email(dgettext("emails", "Confirm your Cannery account"))
|> render_body("confirm_email.html", %{user: user, url: url}) |> render_body("confirm_email.html", %{user: user, url: url})
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
end end
def generate_email("reset_password", user, %{"url" => url}) do def generate_email("reset_password", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Reset your %{name} password", name: "Cannery")) |> base_email(dgettext("emails", "Reset your Cannery password"))
|> render_body("reset_password.html", %{user: user, url: url}) |> render_body("reset_password.html", %{user: user, url: url})
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
end end
def generate_email("update_email", user, %{"url" => url}) do def generate_email("update_email", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Update your %{name} email", name: "Cannery")) |> base_email(dgettext("emails", "Update your Cannery email"))
|> render_body("update_email.html", %{user: user, url: url}) |> render_body("update_email.html", %{user: user, url: url})
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) |> text_body(EmailView.render("update_email.txt", %{user: user, url: url}))
end end

View File

@ -1,4 +1,4 @@
defmodule Cannery.Invites.Invite do defmodule Cannery.Accounts.Invite do
@moduledoc """ @moduledoc """
An invite, created by an admin to allow someone to join their instance. An An invite, created by an admin to allow someone to join their instance. An
invite can be enabled or disabled, and can have an optional number of uses if invite can be enabled or disabled, and can have an optional number of uses if
@ -7,8 +7,8 @@ defmodule Cannery.Invites.Invite do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Ecto.{Changeset, UUID} alias Cannery.Accounts.User
alias Cannery.{Accounts.User, Invites.Invite} alias Ecto.{Association, Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -18,34 +18,39 @@ defmodule Cannery.Invites.Invite do
field :uses_left, :integer, default: nil field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime field :disabled_at, :naive_datetime
belongs_to :user, User belongs_to :created_by, User
has_many :users, User
timestamps() timestamps()
end end
@type t :: %Invite{ @type t :: %__MODULE__{
id: id(), id: id(),
name: String.t(), name: String.t(),
token: String.t(), token: token(),
uses_left: integer() | nil, uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(), disabled_at: NaiveDateTime.t(),
user: User.t(), created_by: User.t() | nil | Association.NotLoaded.t(),
user_id: User.id(), created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_invite :: %Invite{} @type new_invite :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_invite()) @type changeset :: Changeset.t(t() | new_invite())
@type token :: String.t()
@doc false @doc false
@spec create_changeset(User.t(), token :: binary(), attrs :: map()) :: changeset() @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
def create_changeset(%User{id: user_id}, token, attrs) do def create_changeset(%User{id: user_id}, token, attrs) do
%Invite{} %__MODULE__{}
|> change(token: token, user_id: user_id) |> change(token: token, created_by_id: user_id)
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name, :token, :user_id]) |> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0) |> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name, :token, :created_by_id])
end end
@doc false @doc false
@ -53,7 +58,8 @@ defmodule Cannery.Invites.Invite do
def update_changeset(invite, attrs) do def update_changeset(invite, attrs) do
invite invite
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name]) |> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0) |> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name])
end end
end end

View File

@ -0,0 +1,208 @@
defmodule Cannery.Accounts.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Cannery.Accounts.{Invite, User}
alias Cannery.Repo
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite for a user
Raises `Ecto.NoResultsError` if the Invite does not exist.
## Examples
iex> get_invite!(123, %User{id: 123, role: :admin})
%Invite{}
> get_invite!(456, %User{id: 123, role: :admin})
** (Ecto.NoResultsError)
"""
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
def get_invite!(id, %User{role: :admin}) do
Repo.get!(Invite, id)
end
@doc """
Returns if an invite token is still valid
## Examples
iex> valid_invite_token?("valid_token")
%Invite{}
iex> valid_invite_token?("invalid_token")
nil
"""
@spec valid_invite_token?(Invite.token() | nil) :: boolean()
def valid_invite_token?(token) when token in [nil, ""], do: false
def valid_invite_token?(token) do
Repo.exists?(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
end
@doc """
Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
"""
@spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token}
def use_invite(invite_token) do
Multi.new()
|> Multi.run(:invite, fn _changes_so_far, _repo ->
invite_token |> get_invite_by_token()
end)
|> Multi.update(:decrement_invite, fn %{invite: invite} ->
decrement_invite_changeset(invite)
end)
|> Repo.transaction()
|> case do
{:ok, %{decrement_invite: invite}} -> {:ok, invite}
{:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
end
end
@spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token}
defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token}
defp get_invite_by_token(token) do
Repo.one(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
|> case do
nil -> {:error, :invalid_token}
invite -> {:ok, invite}
end
end
@spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | nil
def get_use_count(%Invite{id: invite_id} = invite, user) do
[invite] |> get_use_counts(user) |> Map.get(invite_id)
end
@spec get_use_counts([Invite.t()], User.t()) ::
%{optional(Invite.id()) => non_neg_integer()}
def get_use_counts(invites, %User{role: :admin}) do
invite_ids = invites |> Enum.map(fn %{id: invite_id} -> invite_id end)
Repo.all(
from u in User,
where: u.invite_id in ^invite_ids,
group_by: u.invite_id,
select: {u.invite_id, count(u.id)}
)
|> Map.new()
end
@spec decrement_invite_changeset(Invite.t()) :: Invite.changeset()
defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do
invite |> Invite.update_changeset(%{})
end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end
defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do
invite |> Invite.update_changeset(%{uses_left: uses_left - 1})
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def create_invite(%User{role: :admin} = user, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
Invite.create_changeset(user, token, attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def update_invite(invite, attrs, %User{role: :admin}) do
invite |> Invite.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def delete_invite(invite, %User{role: :admin}) do
invite |> Repo.delete()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}) do
invite |> Repo.delete!()
end
end

View File

@ -1,13 +1,13 @@
defmodule Cannery.Accounts.User do defmodule Cannery.Accounts.User do
@moduledoc """ @moduledoc """
A cannery user A Cannery user
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias Ecto.{Changeset, UUID} alias Ecto.{Association, Changeset, UUID}
alias Cannery.{Accounts.User, Invites.Invite} alias Cannery.Accounts.{Invite, User}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -15,7 +15,9 @@ defmodule Cannery.Accounts.User do
:email, :email,
:confirmed_at, :confirmed_at,
:role, :role,
:locale :locale,
:inserted_at,
:updated_at
]} ]}
@derive {Inspect, except: [:password]} @derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@ -28,7 +30,9 @@ defmodule Cannery.Accounts.User do
field :role, Ecto.Enum, values: [:admin, :user], default: :user field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string field :locale, :string
has_many :invites, Invite, on_delete: :delete_all has_many :created_invites, Invite, foreign_key: :created_by_id
belongs_to :invite, Invite
timestamps() timestamps()
end end
@ -41,14 +45,16 @@ defmodule Cannery.Accounts.User do
confirmed_at: NaiveDateTime.t(), confirmed_at: NaiveDateTime.t(),
role: role(), role: role(),
locale: String.t() | nil, locale: String.t() | nil,
invites: [Invite.t()], created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_user :: %User{} @type new_user :: %User{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_user()) @type changeset :: Changeset.t(t() | new_user())
@type role :: :admin | :user | String.t() @type role :: :admin | :user
@doc """ @doc """
A user changeset for registration. A user changeset for registration.
@ -67,11 +73,13 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
""" """
@spec registration_changeset(attrs :: map()) :: changeset() @spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
@spec registration_changeset(attrs :: map(), opts :: keyword()) :: changeset() @spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
def registration_changeset(attrs, opts \\ []) do def registration_changeset(attrs, invite, opts \\ []) do
%User{} %User{}
|> cast(attrs, [:email, :password, :locale]) |> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id))
|> validate_length(:locale, max: 255)
|> validate_email() |> validate_email()
|> validate_password(opts) |> validate_password(opts)
end end
@ -81,7 +89,7 @@ defmodule Cannery.Accounts.User do
""" """
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset() @spec role_changeset(t() | new_user() | changeset(), role()) :: changeset()
def role_changeset(user, role) do def role_changeset(user, role) do
user |> cast(%{"role" => role}, [:role]) user |> change(role: role)
end end
@spec validate_email(changeset()) :: changeset() @spec validate_email(changeset()) :: changeset()
@ -202,6 +210,7 @@ defmodule Cannery.Accounts.User do
def locale_changeset(user_or_changeset, locale) do def locale_changeset(user_or_changeset, locale) do
user_or_changeset user_or_changeset
|> cast(%{"locale" => locale}, [:locale]) |> cast(%{"locale" => locale}, [:locale])
|> validate_length(:locale, max: 255)
|> validate_required(:locale) |> validate_required(:locale)
end end
end end

View File

@ -1,12 +1,12 @@
defmodule Cannery.Accounts.UserToken do defmodule Cannery.Accounts.UserToken do
@moduledoc """ @moduledoc """
Schema for serialized user session and authentication tokens Schema for a user's session token
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Query import Ecto.Query
alias Ecto.{Query, UUID} alias Cannery.Accounts.User
alias Cannery.{Accounts.User, Accounts.UserToken} alias Ecto.{Association, UUID}
@hash_algorithm :sha256 @hash_algorithm :sha256
@rand_size 32 @rand_size 32
@ -30,27 +30,27 @@ defmodule Cannery.Accounts.UserToken do
timestamps(updated_at: false) timestamps(updated_at: false)
end end
@type t :: %UserToken{ @type t :: %__MODULE__{
id: id(), id: id(),
token: String.t(), token: token(),
context: String.t(), context: String.t(),
sent_to: String.t(), sent_to: String.t(),
user: User.t(), user: User.t() | Association.NotLoaded.t(),
user_id: User.id(), user_id: User.id() | nil,
inserted_at: NaiveDateTime.t() inserted_at: NaiveDateTime.t()
} }
@type new_token :: %UserToken{} @type new_user_token :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type token :: binary()
@doc """ @doc """
Generates a token that will be stored in a signed place, Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those such as session or cookie. As they are signed, those
tokens do not need to be hashed. tokens do not need to be hashed.
""" """
@spec build_session_token(User.t()) :: {token :: String.t(), new_token()} def build_session_token(user) do
def build_session_token(%{id: user_id}) do
token = :crypto.strong_rand_bytes(@rand_size) token = :crypto.strong_rand_bytes(@rand_size)
{token, %UserToken{token: token, context: "session", user_id: user_id}} {token, %__MODULE__{token: token, context: "session", user_id: user.id}}
end end
@doc """ @doc """
@ -58,7 +58,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user found by the token. The query returns the user found by the token.
""" """
@spec verify_session_token_query(token :: String.t()) :: {:ok, Query.t()}
def verify_session_token_query(token) do def verify_session_token_query(token) do
query = query =
from token in token_and_context_query(token, "session"), from token in token_and_context_query(token, "session"),
@ -77,19 +76,16 @@ defmodule Cannery.Accounts.UserToken do
The token is valid for a week as long as users don't change The token is valid for a week as long as users don't change
their email. their email.
""" """
@spec build_email_token(User.t(), context :: String.t()) :: {token :: String.t(), new_token()}
def build_email_token(user, context) do def build_email_token(user, context) do
build_hashed_token(user, context, user.email) build_hashed_token(user, context, user.email)
end end
@spec build_hashed_token(User.t(), String.t(), String.t()) ::
{String.t(), new_token()}
defp build_hashed_token(user, context, sent_to) do defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size) token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token) hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false), {Base.url_encode64(token, padding: false),
%UserToken{ %__MODULE__{
token: hashed_token, token: hashed_token,
context: context, context: context,
sent_to: sent_to, sent_to: sent_to,
@ -102,8 +98,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user found by the token. The query returns the user found by the token.
""" """
@spec verify_email_token_query(token :: String.t(), context :: String.t()) ::
{:ok, Query.t()} | :error
def verify_email_token_query(token, context) do def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->
@ -123,7 +117,6 @@ defmodule Cannery.Accounts.UserToken do
end end
end end
@spec days_for_context(context :: <<_::56>>) :: non_neg_integer()
defp days_for_context("confirm"), do: @confirm_validity_in_days defp days_for_context("confirm"), do: @confirm_validity_in_days
defp days_for_context("reset_password"), do: @reset_password_validity_in_days defp days_for_context("reset_password"), do: @reset_password_validity_in_days
@ -132,8 +125,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user token record. The query returns the user token record.
""" """
@spec verify_change_email_token_query(token :: String.t(), context :: String.t()) ::
{:ok, Query.t()} | :error
def verify_change_email_token_query(token, context) do def verify_change_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} -> {:ok, decoded_token} ->
@ -153,21 +144,18 @@ defmodule Cannery.Accounts.UserToken do
@doc """ @doc """
Returns the given token with the given context. Returns the given token with the given context.
""" """
@spec token_and_context_query(token :: String.t(), context :: String.t()) :: Query.t()
def token_and_context_query(token, context) do def token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context] from __MODULE__, where: [token: ^token, context: ^context]
end end
@doc """ @doc """
Gets all tokens for the given user for the given contexts. Gets all tokens for the given user for the given contexts.
""" """
@spec user_and_contexts_query(User.t(), contexts :: :all | nonempty_maybe_improper_list()) :: def user_and_contexts_query(user, :all) do
Query.t() from t in __MODULE__, where: t.user_id == ^user.id
def user_and_contexts_query(%{id: user_id}, :all) do
from t in UserToken, where: t.user_id == ^user_id
end end
def user_and_contexts_query(%{id: user_id}, [_ | _] = contexts) do def user_and_contexts_query(user, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user_id and t.context in ^contexts from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end end
end end

View File

@ -4,37 +4,55 @@ defmodule Cannery.ActivityLog do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Ecto.Multi alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.{Multi, Queryable}
@doc """ @doc """
Returns the list of shot_groups. Returns the list of shot_groups.
## Examples ## Examples
iex> list_shot_groups(%User{id: 123}) iex> list_shot_groups(:all, %User{id: 123})
[%ShotGroup{}, ...] [%ShotGroup{}, ...]
iex> list_shot_groups("cool", %User{id: 123}) iex> list_shot_groups("cool", :all, %User{id: 123})
[%ShotGroup{notes: "My cool shot group"}, ...] [%ShotGroup{notes: "My cool shot group"}, ...]
iex> list_shot_groups("cool", :rifle, %User{id: 123})
[%ShotGroup{notes: "Shot some rifle rounds"}, ...]
""" """
@spec list_shot_groups(User.t()) :: [ShotGroup.t()] @spec list_shot_groups(AmmoType.type() | :all, User.t()) :: [ShotGroup.t()]
@spec list_shot_groups(search :: nil | String.t(), User.t()) :: [ShotGroup.t()] @spec list_shot_groups(search :: nil | String.t(), AmmoType.type() | :all, User.t()) ::
def list_shot_groups(search \\ nil, user) [ShotGroup.t()]
def list_shot_groups(search \\ nil, type, %{id: user_id}) do
from(sg in ShotGroup,
as: :sg,
left_join: ag in AmmoGroup,
as: :ag,
on: sg.ammo_group_id == ag.id,
left_join: at in AmmoType,
as: :at,
on: ag.ammo_type_id == at.id,
where: sg.user_id == ^user_id,
distinct: sg.id
)
|> list_shot_groups_search(search)
|> list_shot_groups_filter_type(type)
|> Repo.all()
end
def list_shot_groups(search, %{id: user_id}) when search |> is_nil() or search == "", @spec list_shot_groups_search(Queryable.t(), search :: String.t() | nil) ::
do: Repo.all(from sg in ShotGroup, where: sg.user_id == ^user_id) Queryable.t()
defp list_shot_groups_search(query, search) when search in ["", nil], do: query
def list_shot_groups(search, %{id: user_id}) when search |> is_binary() do defp list_shot_groups_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
Repo.all( query
from sg in ShotGroup, |> where(
left_join: ag in assoc(sg, :ammo_group), [sg: sg, ag: ag, at: at],
left_join: at in assoc(ag, :ammo_type),
where: sg.user_id == ^user_id,
where:
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
sg.search, sg.search,
@ -49,15 +67,40 @@ defmodule Cannery.ActivityLog do
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
at.search, at.search,
^trimmed_search ^trimmed_search
), )
order_by: { )
|> order_by([sg: sg], {
:desc, :desc,
fragment( fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sg.search, sg.search,
^trimmed_search ^trimmed_search
) )
} })
end
@spec list_shot_groups_filter_type(Queryable.t(), AmmoType.type() | :all) ::
Queryable.t()
defp list_shot_groups_filter_type(query, :rifle),
do: query |> where([at: at], at.type == :rifle)
defp list_shot_groups_filter_type(query, :pistol),
do: query |> where([at: at], at.type == :pistol)
defp list_shot_groups_filter_type(query, :shotgun),
do: query |> where([at: at], at.type == :shotgun)
defp list_shot_groups_filter_type(query, _all), do: query
@spec list_shot_groups_for_ammo_group(AmmoGroup.t(), User.t()) :: [ShotGroup.t()]
def list_shot_groups_for_ammo_group(
%AmmoGroup{id: ammo_group_id, user_id: user_id},
%User{id: user_id}
) do
Repo.all(
from sg in ShotGroup,
where: sg.ammo_group_id == ^ammo_group_id,
where: sg.user_id == ^user_id
) )
end end
@ -107,9 +150,15 @@ defmodule Cannery.ActivityLog do
) )
|> Multi.run( |> Multi.run(
:ammo_group, :ammo_group,
fn repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok, ammo_group =
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)} Repo.one(
from ag in AmmoGroup,
where: ag.id == ^ammo_group_id,
where: ag.user_id == ^user_id
)
{:ok, ammo_group}
end end
) )
|> Multi.update( |> Multi.update(
@ -220,4 +269,112 @@ defmodule Cannery.ActivityLog do
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """
Returns the number of shot rounds for an ammo group
"""
@spec get_used_count(AmmoGroup.t(), User.t()) :: non_neg_integer()
def get_used_count(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do
[ammo_group]
|> get_used_counts(user)
|> Map.get(ammo_group_id, 0)
end
@doc """
Returns the number of shot rounds for multiple ammo groups
"""
@spec get_used_counts([AmmoGroup.t()], User.t()) ::
%{optional(AmmoGroup.id()) => non_neg_integer()}
def get_used_counts(ammo_groups, %User{id: user_id}) do
ammo_group_ids =
ammo_groups
|> Enum.map(fn %{id: ammo_group_id} -> ammo_group_id end)
Repo.all(
from sg in ShotGroup,
where: sg.ammo_group_id in ^ammo_group_ids,
where: sg.user_id == ^user_id,
group_by: sg.ammo_group_id,
select: {sg.ammo_group_id, sum(sg.count)}
)
|> Map.new()
end
@doc """
Returns the last entered shot group date for an ammo group
"""
@spec get_last_used_date(AmmoGroup.t(), User.t()) :: Date.t() | nil
def get_last_used_date(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do
[ammo_group]
|> get_last_used_dates(user)
|> Map.get(ammo_group_id)
end
@doc """
Returns the last entered shot group date for an ammo group
"""
@spec get_last_used_dates([AmmoGroup.t()], User.t()) :: %{optional(AmmoGroup.id()) => Date.t()}
def get_last_used_dates(ammo_groups, %User{id: user_id}) do
ammo_group_ids =
ammo_groups
|> Enum.map(fn %AmmoGroup{id: ammo_group_id, user_id: ^user_id} -> ammo_group_id end)
Repo.all(
from sg in ShotGroup,
where: sg.ammo_group_id in ^ammo_group_ids,
where: sg.user_id == ^user_id,
group_by: sg.ammo_group_id,
select: {sg.ammo_group_id, max(sg.date)}
)
|> Map.new()
end
@doc """
Gets the total number of rounds shot for an ammo type
Raises `Ecto.NoResultsError` if the Ammo type does not exist.
## Examples
iex> get_used_count_for_ammo_type(123, %User{id: 123})
35
iex> get_used_count_for_ammo_type(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_used_count_for_ammo_type(AmmoType.t(), User.t()) :: non_neg_integer()
def get_used_count_for_ammo_type(%AmmoType{id: ammo_type_id} = ammo_type, user) do
[ammo_type]
|> get_used_count_for_ammo_types(user)
|> Map.get(ammo_type_id, 0)
end
@doc """
Gets the total number of rounds shot for multiple ammo types
## Examples
iex> get_used_count_for_ammo_types(123, %User{id: 123})
35
"""
@spec get_used_count_for_ammo_types([AmmoType.t()], User.t()) ::
%{optional(AmmoType.id()) => non_neg_integer()}
def get_used_count_for_ammo_types(ammo_types, %User{id: user_id}) do
ammo_type_ids =
ammo_types
|> Enum.map(fn %AmmoType{id: ammo_type_id, user_id: ^user_id} -> ammo_type_id end)
Repo.all(
from ag in AmmoGroup,
left_join: sg in ShotGroup,
on: ag.id == sg.ammo_group_id,
where: ag.ammo_type_id in ^ammo_type_ids,
where: not (sg.count |> is_nil()),
group_by: ag.ammo_type_id,
select: {ag.ammo_type_id, sum(sg.count)}
)
|> Map.new()
end
end end

View File

@ -6,7 +6,7 @@ defmodule Cannery.ActivityLog.ShotGroup do
use Ecto.Schema use Ecto.Schema
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Changeset import Ecto.Changeset
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup}
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -24,25 +24,23 @@ defmodule Cannery.ActivityLog.ShotGroup do
field :date, :date field :date, :date
field :notes, :string field :notes, :string
belongs_to :user, User field :user_id, :binary_id
belongs_to :ammo_group, AmmoGroup field :ammo_group_id, :binary_id
timestamps() timestamps()
end end
@type t :: %ShotGroup{ @type t :: %__MODULE__{
id: id(), id: id(),
count: integer, count: integer,
notes: String.t() | nil, notes: String.t() | nil,
date: Date.t() | nil, date: Date.t() | nil,
ammo_group: AmmoGroup.t() | nil,
ammo_group_id: AmmoGroup.id(), ammo_group_id: AmmoGroup.id(),
user: User.t() | nil,
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_shot_group :: %ShotGroup{} @type new_shot_group :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_group()) @type changeset :: Changeset.t(t() | new_shot_group())
@ -58,44 +56,52 @@ defmodule Cannery.ActivityLog.ShotGroup do
%User{id: user_id}, %User{id: user_id},
%AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group, %AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group,
attrs attrs
) ) do
when not (user_id |> is_nil()) and not (ammo_group_id |> is_nil()) do
shot_group shot_group
|> change(user_id: user_id) |> change(user_id: user_id)
|> change(ammo_group_id: ammo_group_id) |> change(ammo_group_id: ammo_group_id)
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0) |> validate_length(:notes, max: 255)
|> validate_create_shot_group_count(ammo_group) |> validate_create_shot_group_count(ammo_group)
|> validate_required([:count, :date, :ammo_group_id, :user_id]) |> validate_required([:date, :ammo_group_id, :user_id])
end end
def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do
shot_group shot_group
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0) |> validate_length(:notes, max: 255)
|> validate_required([:count, :ammo_group_id, :user_id]) |> validate_required([:ammo_group_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack")) |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end end
defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do
if changeset |> Changeset.get_field(:count) > ammo_group_count do case changeset |> Changeset.get_field(:count) do
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) nil ->
changeset |> Changeset.add_error(:count, error) changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
else
count when count > ammo_group_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds",
count: ammo_group_count - 1
)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset changeset
end end
end end
@doc false @doc false
@spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset()
def update_changeset( def update_changeset(%__MODULE__{} = shot_group, user, attrs) do
%ShotGroup{user_id: user_id} = shot_group,
%User{id: user_id} = user,
attrs
)
when not (user_id |> is_nil()) do
shot_group shot_group
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0) |> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date]) |> validate_required([:count, :date])
|> validate_update_shot_group_count(shot_group, user) |> validate_update_shot_group_count(shot_group, user)
@ -103,25 +109,20 @@ defmodule Cannery.ActivityLog.ShotGroup do
defp validate_update_shot_group_count( defp validate_update_shot_group_count(
changeset, changeset,
%ShotGroup{count: count} = shot_group, %__MODULE__{ammo_group_id: ammo_group_id, count: count},
%User{id: user_id} user
) ) do
when not (user_id |> is_nil()) do %{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user)
%{ammo_group: %AmmoGroup{count: ammo_group_count, user_id: ^user_id}} =
shot_group |> Repo.preload(:ammo_group)
new_shot_group_count = changeset |> Changeset.get_field(:count) new_shot_group_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_group_count - count shot_diff_to_add = new_shot_group_count - count
cond do if shot_diff_to_add > ammo_group_count do
shot_diff_to_add > ammo_group_count -> error =
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count)
changeset |> Changeset.add_error(:count, error) changeset |> Changeset.add_error(:count, error)
else
new_shot_group_count <= 0 ->
changeset |> Changeset.add_error(:count, dgettext("errors", "Count must be at least 1"))
true ->
changeset changeset
end end
end end

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,8 @@ defmodule Cannery.Ammo.AmmoGroup do
use Ecto.Schema use Ecto.Schema
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Changeset import Ecto.Changeset
alias Cannery.Ammo.{AmmoGroup, AmmoType} alias Cannery.Ammo.AmmoType
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers, Containers.Container} alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -33,15 +33,13 @@ defmodule Cannery.Ammo.AmmoGroup do
field :purchased_on, :date field :purchased_on, :date
belongs_to :ammo_type, AmmoType belongs_to :ammo_type, AmmoType
belongs_to :container, Container field :container_id, :binary_id
belongs_to :user, User field :user_id, :binary_id
has_many :shot_groups, ShotGroup
timestamps() timestamps()
end end
@type t :: %AmmoGroup{ @type t :: %__MODULE__{
id: id(), id: id(),
count: integer, count: integer,
notes: String.t() | nil, notes: String.t() | nil,
@ -50,14 +48,12 @@ defmodule Cannery.Ammo.AmmoGroup do
purchased_on: Date.t(), purchased_on: Date.t(),
ammo_type: AmmoType.t() | nil, ammo_type: AmmoType.t() | nil,
ammo_type_id: AmmoType.id(), ammo_type_id: AmmoType.id(),
container: Container.t() | nil,
container_id: Container.id(), container_id: Container.id(),
user: User.t() | nil,
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_ammo_group :: %AmmoGroup{} @type new_ammo_group :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_group()) @type changeset :: Changeset.t(t() | new_ammo_group())
@ -76,8 +72,7 @@ defmodule Cannery.Ammo.AmmoGroup do
%User{id: user_id}, %User{id: user_id},
attrs attrs
) )
when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do
not (user_id |> is_nil()) do
ammo_group ammo_group
|> change(ammo_type_id: ammo_type_id) |> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id) |> change(user_id: user_id)

View File

@ -8,7 +8,7 @@ defmodule Cannery.Ammo.AmmoType do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias Cannery.Ammo.{AmmoGroup, AmmoType} alias Cannery.Ammo.AmmoGroup
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -42,39 +42,57 @@ defmodule Cannery.Ammo.AmmoType do
field :name, :string field :name, :string
field :desc, :string field :desc, :string
# https://en.wikipedia.org/wiki/Bullet#Abbreviations field :type, Ecto.Enum, values: [:rifle, :shotgun, :pistol]
# common fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string field :bullet_type, :string
field :bullet_core, :string field :bullet_core, :string
field :cartridge, :string # also gauge for shotguns
field :caliber, :string field :caliber, :string
field :case_material, :string field :case_material, :string
field :jacket_type, :string
field :muzzle_velocity, :integer
field :powder_type, :string field :powder_type, :string
field :powder_grains_per_charge, :integer
field :grains, :integer field :grains, :integer
field :pressure, :string field :pressure, :string
field :primer_type, :string field :primer_type, :string
field :firing_type, :string field :firing_type, :string
field :manufacturer, :string
field :upc, :string
field :tracer, :boolean, default: false field :tracer, :boolean, default: false
field :incendiary, :boolean, default: false field :incendiary, :boolean, default: false
field :blank, :boolean, default: false field :blank, :boolean, default: false
field :corrosive, :boolean, default: false field :corrosive, :boolean, default: false
field :manufacturer, :string # rifle/pistol fields
field :upc, :string field :cartridge, :string
field :jacket_type, :string
field :powder_grains_per_charge, :integer
field :muzzle_velocity, :integer
belongs_to :user, User # shotgun fields
field :wadding, :string
field :shot_type, :string
field :shot_material, :string
field :shot_size, :string
field :unfired_length, :string
field :brass_height, :string
field :chamber_size, :string
field :load_grains, :integer
field :shot_charge_weight, :string
field :dram_equivalent, :string
field :user_id, :binary_id
has_many :ammo_groups, AmmoGroup has_many :ammo_groups, AmmoGroup
timestamps() timestamps()
end end
@type t :: %AmmoType{ @type t :: %__MODULE__{
id: id(), id: id(),
name: String.t(), name: String.t(),
desc: String.t() | nil, desc: String.t() | nil,
type: type(),
bullet_type: String.t() | nil, bullet_type: String.t() | nil,
bullet_core: String.t() | nil, bullet_core: String.t() | nil,
cartridge: String.t() | nil, cartridge: String.t() | nil,
@ -88,6 +106,16 @@ defmodule Cannery.Ammo.AmmoType do
pressure: String.t() | nil, pressure: String.t() | nil,
primer_type: String.t() | nil, primer_type: String.t() | nil,
firing_type: String.t() | nil, firing_type: String.t() | nil,
wadding: String.t() | nil,
shot_type: String.t() | nil,
shot_material: String.t() | nil,
shot_size: String.t() | nil,
unfired_length: String.t() | nil,
brass_height: String.t() | nil,
chamber_size: String.t() | nil,
load_grains: integer() | nil,
shot_charge_weight: String.t() | nil,
dram_equivalent: String.t() | nil,
tracer: boolean(), tracer: boolean(),
incendiary: boolean(), incendiary: boolean(),
blank: boolean(), blank: boolean(),
@ -95,20 +123,21 @@ defmodule Cannery.Ammo.AmmoType do
manufacturer: String.t() | nil, manufacturer: String.t() | nil,
upc: String.t() | nil, upc: String.t() | nil,
user_id: User.id(), user_id: User.id(),
user: User.t() | nil,
ammo_groups: [AmmoGroup.t()] | nil, ammo_groups: [AmmoGroup.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_ammo_type :: %AmmoType{} @type new_ammo_type :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_type()) @type changeset :: Changeset.t(t() | new_ammo_type())
@type type :: :rifle | :shotgun | :pistol | nil
@spec changeset_fields() :: [atom()] @spec changeset_fields() :: [atom()]
defp changeset_fields, defp changeset_fields,
do: [ do: [
:name, :name,
:desc, :desc,
:type,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge, :cartridge,
@ -122,6 +151,16 @@ defmodule Cannery.Ammo.AmmoType do
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:load_grains,
:shot_charge_weight,
:dram_equivalent,
:tracer, :tracer,
:incendiary, :incendiary,
:blank, :blank,
@ -130,20 +169,55 @@ defmodule Cannery.Ammo.AmmoType do
:upc :upc
] ]
@spec string_fields() :: [atom()]
defp string_fields,
do: [
:name,
:bullet_type,
:bullet_core,
:cartridge,
:caliber,
:case_material,
:jacket_type,
:powder_type,
:pressure,
:primer_type,
:firing_type,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:shot_charge_weight,
:dram_equivalent,
:manufacturer,
:upc
]
@doc false @doc false
@spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset() @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(ammo_type, %User{id: user_id}, attrs) do def create_changeset(ammo_type, %User{id: user_id}, attrs) do
changeset =
ammo_type ammo_type
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required([:name, :user_id]) |> validate_required([:name, :user_id])
end end
@doc false @doc false
@spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset()
def update_changeset(ammo_type, attrs) do def update_changeset(ammo_type, attrs) do
changeset =
ammo_type ammo_type
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required(:name) |> validate_required(:name)
end end
end end

View File

@ -4,6 +4,7 @@ defmodule Cannery.Application do
@moduledoc false @moduledoc false
use Application use Application
alias Cannery.Logger
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
@ -17,16 +18,24 @@ defmodule Cannery.Application do
# Start the Endpoint (http/https) # Start the Endpoint (http/https)
CanneryWeb.Endpoint, CanneryWeb.Endpoint,
# Add Oban # Add Oban
{Oban, oban_config()} {Oban, oban_config()},
Cannery.Repo.Migrator
# Start a worker by calling: Cannery.Worker.start_link(arg) # Start a worker by calling: Cannery.Worker.start_link(arg)
# {Cannery.Worker, arg} # {Cannery.Worker, arg}
] ]
# Automatically migrate on start in prod # Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors
children = :ok =
if Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate], :telemetry.attach_many(
do: children ++ [Cannery.Repo.Migrator], "oban-logger",
else: children [
[:oban, :job, :exception],
[:oban, :job, :start],
[:oban, :job, :stop]
],
&Logger.handle_event/4,
[]
)
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options # for other strategies and supported options

View File

@ -0,0 +1,12 @@
defmodule Cannery.ComparableDate do
@moduledoc """
A custom `Date` module that provides a `compare/2` function that is comparable
with nil values
"""
@spec compare(Date.t() | any(), Date.t() | any()) :: :lt | :gt | :eq
def compare(%Date{} = date_1, %Date{} = date_2), do: Date.compare(date_1, date_2)
def compare(%Date{}, _date_2), do: :lt
def compare(_date_1, %Date{}), do: :gt
def compare(_date_1, _date_2), do: :eq
end

View File

@ -0,0 +1,15 @@
defmodule Cannery.ComparableDateTime do
@moduledoc """
A custom `DateTime` module that provides a `compare/2` function that is
comparable with nil values
"""
@spec compare(DateTime.t() | any(), DateTime.t() | any()) :: :lt | :gt | :eq
def compare(%DateTime{} = datetime_1, %DateTime{} = datetime_2) do
DateTime.compare(datetime_1, datetime_2)
end
def compare(%DateTime{}, _datetime_2), do: :lt
def compare(_datetime_1, %DateTime{}), do: :gt
def compare(_datetime_1, _datetime_2), do: :eq
end

View File

@ -5,10 +5,12 @@ defmodule Cannery.Containers do
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag} alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo}
alias Cannery.Containers.{Container, ContainerTag} alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.Changeset alias Ecto.Changeset
@container_preloads [:tags]
@doc """ @doc """
Returns the list of containers. Returns the list of containers.
@ -28,11 +30,10 @@ defmodule Cannery.Containers do
as: :c, as: :c,
left_join: t in assoc(c, :tags), left_join: t in assoc(c, :tags),
as: :t, as: :t,
left_join: ag in assoc(c, :ammo_groups),
as: :ag,
where: c.user_id == ^user_id, where: c.user_id == ^user_id,
order_by: c.name, order_by: c.name,
preload: [tags: t, ammo_groups: ag] distinct: c.id,
preload: ^@container_preloads
) )
|> list_containers_search(search) |> list_containers_search(search)
|> Repo.all() |> Repo.all()
@ -91,7 +92,7 @@ defmodule Cannery.Containers do
@doc """ @doc """
Gets a single container. Gets a single container.
Raises `Ecto.NoResultsError` if the Container does not exist. Raises `KeyError` if the Container does not exist.
## Examples ## Examples
@ -99,20 +100,37 @@ defmodule Cannery.Containers do
%Container{} %Container{}
iex> get_container!(456, %User{id: 123}) iex> get_container!(456, %User{id: 123})
** (Ecto.NoResultsError) ** (KeyError)
""" """
@spec get_container!(Container.id(), User.t()) :: Container.t() @spec get_container!(Container.id(), User.t()) :: Container.t()
def get_container!(id, %User{id: user_id}) do def get_container!(id, user) do
Repo.one!( [id]
|> get_containers(user)
|> Map.fetch!(id)
end
@doc """
Gets multiple containers.
## Examples
iex> get_containers([123], %User{id: 123})
%{123 => %Container{}}
"""
@spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()}
def get_containers(ids, %User{id: user_id}) do
Repo.all(
from c in Container, from c in Container,
left_join: t in assoc(c, :tags),
left_join: ag in assoc(c, :ammo_groups),
where: c.user_id == ^user_id, where: c.user_id == ^user_id,
where: c.id == ^id, where: c.id in ^ids,
order_by: c.name, order_by: c.name,
preload: [tags: t, ammo_groups: ag] preload: ^@container_preloads,
select: {c.id, c}
) )
|> Map.new()
end end
@doc """ @doc """
@ -130,7 +148,19 @@ defmodule Cannery.Containers do
@spec create_container(attrs :: map(), User.t()) :: @spec create_container(attrs :: map(), User.t()) ::
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Container.changeset()}
def create_container(attrs, %User{} = user) do def create_container(attrs, %User{} = user) do
%Container{} |> Container.create_changeset(user, attrs) |> Repo.insert() %Container{}
|> Container.create_changeset(user, attrs)
|> Repo.insert()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end
@spec preload_container(Container.t()) :: Container.t()
@spec preload_container([Container.t()]) :: [Container.t()]
def preload_container(container) do
container |> Repo.preload(@container_preloads)
end end
@doc """ @doc """
@ -148,7 +178,13 @@ defmodule Cannery.Containers do
@spec update_container(Container.t(), User.t(), attrs :: map()) :: @spec update_container(Container.t(), User.t(), attrs :: map()) ::
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Container.changeset()}
def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do
container |> Container.update_changeset(attrs) |> Repo.update() container
|> Container.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end end
@doc """ @doc """
@ -173,7 +209,12 @@ defmodule Cannery.Containers do
) )
|> case do |> case do
0 -> 0 ->
container |> Repo.delete() container
|> Repo.delete()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
_amount -> _amount ->
error = dgettext("errors", "Container must be empty before deleting") error = dgettext("errors", "Container must be empty before deleting")
@ -214,8 +255,11 @@ defmodule Cannery.Containers do
%Container{user_id: user_id} = container, %Container{user_id: user_id} = container,
%Tag{user_id: user_id} = tag, %Tag{user_id: user_id} = tag,
%User{id: user_id} %User{id: user_id}
), ) do
do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!() %ContainerTag{}
|> ContainerTag.create_changeset(tag, container)
|> Repo.insert!()
end
@doc """ @doc """
Removes a tag from a container Removes a tag from a container
@ -226,45 +270,175 @@ defmodule Cannery.Containers do
%Container{} %Container{}
""" """
@spec remove_tag!(Container.t(), Tag.t(), User.t()) :: non_neg_integer() @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: {non_neg_integer(), [ContainerTag.t()]}
def remove_tag!( def remove_tag!(
%Container{id: container_id, user_id: user_id}, %Container{id: container_id, user_id: user_id},
%Tag{id: tag_id, user_id: user_id}, %Tag{id: tag_id, user_id: user_id},
%User{id: user_id} %User{id: user_id}
) do ) do
{count, _} = {count, results} =
Repo.delete_all( Repo.delete_all(
from ct in ContainerTag, from ct in ContainerTag,
where: ct.container_id == ^container_id, where: ct.container_id == ^container_id,
where: ct.tag_id == ^tag_id where: ct.tag_id == ^tag_id,
select: ct
) )
if count == 0, do: raise("could not delete container tag"), else: count if count == 0, do: raise("could not delete container tag"), else: {count, results}
end
# Container Tags
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags("cool", %User{id: 123})
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()]
def list_tags(search \\ nil, user)
def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "",
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
def list_tags(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search)
Repo.all(
from t in Tag,
where: t.user_id == ^user_id,
where:
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
),
order_by: {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
}
)
end end
@doc """ @doc """
Returns number of rounds in container. If data is already preloaded, then Gets a single tag.
there will be no db hit.
## Examples
iex> get_tag(123, %User{id: 123})
{:ok, %Tag{}}
iex> get_tag(456, %User{id: 123})
{:error, :not_found}
""" """
@spec get_container_ammo_group_count!(Container.t()) :: non_neg_integer() @spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, :not_found}
def get_container_ammo_group_count!(%Container{} = container) do def get_tag(id, %User{id: user_id}) do
container Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
|> Repo.preload(:ammo_groups) |> case do
|> Map.fetch!(:ammo_groups) nil -> {:error, :not_found}
|> Enum.reject(fn %{count: count} -> count == 0 end) tag -> {:ok, tag}
|> Enum.count() end
end end
@doc """ @doc """
Returns number of rounds in container. If data is already preloaded, then Gets a single tag.
there will be no db hit.
Raises `Ecto.NoResultsError` if the Tag does not exist.
## Examples
iex> get_tag!(123, %User{id: 123})
%Tag{}
iex> get_tag!(456, %User{id: 123})
** (Ecto.NoResultsError)
""" """
@spec get_container_rounds!(Container.t()) :: non_neg_integer() @spec get_tag!(Tag.id(), User.t()) :: Tag.t()
def get_container_rounds!(%Container{} = container) do def get_tag!(id, %User{id: user_id}) do
container Repo.one!(
|> Repo.preload(:ammo_groups) from t in Tag,
|> Map.fetch!(:ammo_groups) where: t.id == ^id,
|> Enum.map(fn %{count: count} -> count end) where: t.user_id == ^user_id
|> Enum.sum() )
end
@doc """
Creates a tag.
## Examples
iex> create_tag(%{field: value}, %User{id: 123})
{:ok, %Tag{}}
iex> create_tag(%{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec create_tag(attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def create_tag(attrs, %User{} = user) do
%Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert()
end
@doc """
Updates a tag.
## Examples
iex> update_tag(tag, %{field: new_value}, %User{id: 123})
{:ok, %Tag{}}
iex> update_tag(tag, %{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec update_tag(Tag.t(), attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}) do
tag |> Tag.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a tag.
## Examples
iex> delete_tag(tag, %User{id: 123})
{:ok, %Tag{}}
iex> delete_tag(tag, %User{id: 123})
{:error, %Changeset{}}
"""
@spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()}
def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}) do
tag |> Repo.delete()
end
@doc """
Deletes a tag.
## Examples
iex> delete_tag!(tag, %User{id: 123})
%Tag{}
"""
@spec delete_tag!(Tag.t(), User.t()) :: Tag.t()
def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}) do
tag |> Repo.delete!()
end end
end end

View File

@ -6,8 +6,7 @@ defmodule Cannery.Containers.Container do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
alias Cannery.Containers.{Container, ContainerTag} alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag}
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -26,28 +25,25 @@ defmodule Cannery.Containers.Container do
field :location, :string field :location, :string
field :type, :string field :type, :string
belongs_to :user, User field :user_id, :binary_id
has_many :ammo_groups, AmmoGroup
many_to_many :tags, Tag, join_through: ContainerTag many_to_many :tags, Tag, join_through: ContainerTag
timestamps() timestamps()
end end
@type t :: %Container{ @type t :: %__MODULE__{
id: id(), id: id(),
name: String.t(), name: String.t(),
desc: String.t(), desc: String.t(),
location: String.t(), location: String.t(),
type: String.t(), type: String.t(),
user: User.t(),
user_id: User.id(), user_id: User.id(),
ammo_groups: [AmmoGroup.t()] | nil,
tags: [Tag.t()] | nil, tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_container :: %Container{} @type new_container :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container()) @type changeset :: Changeset.t(t() | new_container())
@ -57,6 +53,8 @@ defmodule Cannery.Containers.Container do
container container
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id]) |> validate_required([:name, :type, :user_id])
end end
@ -65,6 +63,8 @@ defmodule Cannery.Containers.Container do
def update_changeset(container, attrs) do def update_changeset(container, attrs) do
container container
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type]) |> validate_required([:name, :type])
end end
end end

View File

@ -1,12 +1,12 @@
defmodule Cannery.Containers.ContainerTag do defmodule Cannery.Containers.ContainerTag do
@moduledoc """ @moduledoc """
Thru-table struct for associating Cannery.Containers.Container and Thru-table struct for associating Cannery.Containers.Container and
Cannery.Tags.Tag. Cannery.Containers.Tag.
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag} alias Cannery.Containers.{Container, Tag}
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@ -18,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do
timestamps() timestamps()
end end
@type t :: %ContainerTag{ @type t :: %__MODULE__{
id: id(), id: id(),
container: Container.t(), container: Container.t(),
container_id: Container.id(), container_id: Container.id(),
@ -27,7 +27,7 @@ defmodule Cannery.Containers.ContainerTag do
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_container_tag :: %ContainerTag{} @type new_container_tag :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container_tag()) @type changeset :: Changeset.t(t() | new_container_tag())

View File

@ -1,4 +1,4 @@
defmodule Cannery.Tags.Tag do defmodule Cannery.Containers.Tag do
@moduledoc """ @moduledoc """
Tags are added to containers to help organize, and can include custom-defined Tags are added to containers to help organize, and can include custom-defined
text and bg colors. text and bg colors.
@ -6,8 +6,8 @@ defmodule Cannery.Tags.Tag do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Tags.Tag}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -23,22 +23,21 @@ defmodule Cannery.Tags.Tag do
field :bg_color, :string field :bg_color, :string
field :text_color, :string field :text_color, :string
belongs_to :user, User field :user_id, :binary_id
timestamps() timestamps()
end end
@type t :: %Tag{ @type t :: %__MODULE__{
id: id(), id: id(),
name: String.t(), name: String.t(),
bg_color: String.t(), bg_color: String.t(),
text_color: String.t(), text_color: String.t(),
user: User.t() | nil,
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_tag() :: %Tag{} @type new_tag() :: %__MODULE__{}
@type id() :: UUID.t() @type id() :: UUID.t()
@type changeset() :: Changeset.t(t() | new_tag()) @type changeset() :: Changeset.t(t() | new_tag())
@ -48,6 +47,9 @@ defmodule Cannery.Tags.Tag do
tag tag
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :bg_color, :text_color]) |> cast(attrs, [:name, :bg_color, :text_color])
|> validate_length(:name, max: 255)
|> validate_length(:bg_color, max: 12)
|> validate_length(:text_color, max: 12)
|> validate_required([:name, :bg_color, :text_color, :user_id]) |> validate_required([:name, :bg_color, :text_color, :user_id])
end end
@ -56,6 +58,9 @@ defmodule Cannery.Tags.Tag do
def update_changeset(tag, attrs) do def update_changeset(tag, attrs) do
tag tag
|> cast(attrs, [:name, :bg_color, :text_color]) |> cast(attrs, [:name, :bg_color, :text_color])
|> validate_length(:name, max: 255)
|> validate_length(:bg_color, max: 12)
|> validate_length(:text_color, max: 12)
|> validate_required([:name, :bg_color, :text_color]) |> validate_required([:name, :bg_color, :text_color])
end end
end end

View File

@ -1,155 +0,0 @@
defmodule Cannery.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Invites.Invite, 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.
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, 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()
@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()
@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
View 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

View File

@ -9,7 +9,9 @@ defmodule Cannery.Release do
def rollback(repo, version) do def rollback(repo, version) do
load_app() load_app()
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
{:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end end
defp load_app do defp load_app do
@ -20,7 +22,8 @@ defmodule Cannery.Release do
load_app() load_app()
for repo <- Application.fetch_env!(@app, :ecto_repos) do for repo <- Application.fetch_env!(@app, :ecto_repos) do
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) {:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end end
end end
end end

View File

@ -1,6 +1,6 @@
defmodule Cannery.Repo.Migrator do defmodule Cannery.Repo.Migrator do
@moduledoc """ @moduledoc """
Genserver to automatically run migrations in prod env Genserver to automatically perform all migration on app start
""" """
use GenServer use GenServer
@ -11,12 +11,15 @@ defmodule Cannery.Repo.Migrator do
end end
def init(_opts) do def init(_opts) do
migrate!() {:ok, if(automigrate_enabled?(), do: migrate!())}
{:ok, nil}
end end
def migrate! do def migrate! do
path = Application.app_dir(:cannery, "priv/repo/migrations") path = Application.app_dir(:cannery, "priv/repo/migrations")
Ecto.Migrator.run(Cannery.Repo, path, :up, all: true) Ecto.Migrator.run(Cannery.Repo, path, :up, all: true)
end end
defp automigrate_enabled? do
Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate]
end
end end

View File

@ -1,149 +0,0 @@
defmodule Cannery.Tags do
@moduledoc """
The Tags context.
"""
import Ecto.Query, warn: false
import CanneryWeb.Gettext
alias Cannery.{Accounts.User, Repo, Tags.Tag}
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags("cool", %User{id: 123})
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()]
def list_tags(search \\ nil, user)
def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "",
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
def list_tags(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search)
Repo.all(
from t in Tag,
where: t.user_id == ^user_id,
where:
fragment(
"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.
## Examples
iex> get_tag(123, %User{id: 123})
{:ok, %Tag{}}
iex> get_tag(456, %User{id: 123})
{:error, "tag not found"}
"""
@spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, String.t()}
def get_tag(id, %User{id: user_id}) do
Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
|> case do
nil -> {:error, dgettext("errors", "Tag not found")}
tag -> {:ok, tag}
end
end
@doc """
Gets a single tag.
Raises `Ecto.NoResultsError` if the Tag does not exist.
## Examples
iex> get_tag!(123, %User{id: 123})
%Tag{}
iex> get_tag!(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_tag!(Tag.id(), User.t()) :: Tag.t()
def get_tag!(id, %User{id: user_id}),
do: Repo.one!(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
@doc """
Creates a tag.
## Examples
iex> create_tag(%{field: value}, %User{id: 123})
{:ok, %Tag{}}
iex> create_tag(%{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec create_tag(attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def create_tag(attrs, %User{} = user),
do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert()
@doc """
Updates a tag.
## Examples
iex> update_tag(tag, %{field: new_value}, %User{id: 123})
{:ok, %Tag{}}
iex> update_tag(tag, %{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec update_tag(Tag.t(), attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}),
do: tag |> Tag.update_changeset(attrs) |> Repo.update()
@doc """
Deletes a tag.
## Examples
iex> delete_tag(tag, %User{id: 123})
{:ok, %Tag{}}
iex> delete_tag(tag, %User{id: 123})
{:error, %Changeset{}}
"""
@spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()}
def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete()
@doc """
Deletes a tag.
## Examples
iex> delete_tag!(tag, %User{id: 123})
%Tag{}
"""
@spec delete_tag!(Tag.t(), User.t()) :: Tag.t()
def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!()
end

View File

@ -44,8 +44,7 @@ defmodule CanneryWeb do
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live}
layout: {CanneryWeb.LayoutView, "live.html"}
on_mount CanneryWeb.InitAssigns on_mount CanneryWeb.InitAssigns
unquote(view_helpers()) unquote(view_helpers())
@ -72,16 +71,14 @@ defmodule CanneryWeb do
quote do quote do
use Phoenix.Router use Phoenix.Router
import Phoenix.{Controller, LiveView.Router}
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end end
end end
def channel do def channel do
quote do quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Channel use Phoenix.Channel
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.Gettext import CanneryWeb.Gettext
@ -95,15 +92,10 @@ defmodule CanneryWeb do
use Phoenix.HTML use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Phoenix.Component
# Import basic rendering functionality (render, render_layout, etc) # Import basic rendering functionality (render, render_layout, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers}
import Phoenix.View import Phoenix.{Component, View}
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
alias CanneryWeb.Endpoint alias CanneryWeb.Endpoint
alias CanneryWeb.Router.Helpers, as: Routes alias CanneryWeb.Router.Helpers, as: Routes
end end

View File

@ -5,6 +5,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup}
alias Ecto.Changeset
alias Phoenix.LiveView.{JS, Socket} alias Phoenix.LiveView.{JS, Socket}
@impl true @impl true
@ -18,7 +19,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do
changeset = changeset =
%ShotGroup{date: NaiveDateTime.utc_now(), count: 1} %ShotGroup{date: Date.utc_today()}
|> ShotGroup.create_changeset(current_user, ammo_group, %{}) |> ShotGroup.create_changeset(current_user, ammo_group, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
@ -32,10 +33,13 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
) do ) do
params = shot_group_params |> process_params(ammo_group) params = shot_group_params |> process_params(ammo_group)
changeset = %ShotGroup{} |> ShotGroup.create_changeset(current_user, ammo_group, params)
changeset = changeset =
%ShotGroup{} case changeset |> Changeset.apply_action(:validate) do
|> ShotGroup.create_changeset(current_user, ammo_group, params) {:ok, _data} -> changeset
|> Map.put(:action, :validate) {:error, changeset} -> changeset
end
{:noreply, socket |> assign(:changeset, changeset)} {:noreply, socket |> assign(:changeset, changeset)}
end end
@ -56,7 +60,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
prompt = dgettext("prompts", "Shots recorded successfully") prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset) socket |> assign(changeset: changeset)
end end
@ -65,14 +69,14 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do
# calculate count from shots left # calculate count from shots left
defp process_params(params, %AmmoGroup{count: count}) do defp process_params(params, %AmmoGroup{count: count}) do
new_count = shot_group_count =
if params |> Map.get("ammo_left", "0") == "" do if params |> Map.get("ammo_left", "") == "" do
"0" nil
else else
params |> Map.get("ammo_left", "0") new_count = params |> Map.get("ammo_left") |> String.to_integer()
count - new_count
end end
|> String.to_integer()
params |> Map.put("count", count - new_count) params |> Map.put("count", shot_group_count)
end end
end end

View File

@ -12,11 +12,12 @@
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
> >
<%= if @changeset.action && not @changeset.valid? do %> <div
<div class="invalid-feedback col-span-3 text-center"> :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<% end %>
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :ammo_left, <%= number_input(f, :ammo_left,
@ -36,9 +37,12 @@
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes, <%= textarea(f, :notes,
id: "add-shot-group-form-notes",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: "Really great weather", maxlength: 255,
phx_hook: "MaintainAttrs" placeholder: gettext("Really great weather"),
phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :notes, "col-span-3") %> <%= error_tag(f, :notes, "col-span-3") %>

View File

@ -1,106 +0,0 @@
defmodule CanneryWeb.Components.AmmoGroupCard do
@moduledoc """
Display card for an ammo group
"""
use CanneryWeb, :component
alias Cannery.{Ammo, Ammo.AmmoGroup, Repo}
alias CanneryWeb.Endpoint
attr :ammo_group, AmmoGroup, required: true
attr :show_container, :boolean, default: false
slot(:inner_block)
def ammo_group_card(%{ammo_group: ammo_group} = assigns) do
assigns =
%{show_container: show_container} = assigns |> assign_new(:show_container, fn -> false end)
preloads = if show_container, do: [:ammo_type, :container], else: [:ammo_type]
ammo_group = ammo_group |> Repo.preload(preloads)
assigns = assigns |> assign(:ammo_group, ammo_group)
~H"""
<div
id={"ammo_group-#{@ammo_group.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
<%= @ammo_group.ammo_type.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
<%= gettext("Count:") %>
<%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %>
</span>
<%= if @ammo_group |> Ammo.get_original_count() != @ammo_group.count do %>
<span class="rounded-lg title text-lg">
<%= gettext("Original Count:") %>
<%= @ammo_group |> Ammo.get_original_count() %>
</span>
<% end %>
<%= if @ammo_group.notes do %>
<span class="rounded-lg title text-lg">
<%= gettext("Notes:") %>
<%= @ammo_group.notes %>
</span>
<% end %>
<span class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %>
<%= @ammo_group.purchased_on |> display_date() %>
</span>
<%= if @ammo_group |> Ammo.get_last_used_shot_group() do %>
<span class="rounded-lg title text-lg">
<%= gettext("Last used on:") %>
<%= @ammo_group |> Ammo.get_last_used_shot_group() |> Map.get(:date) |> display_date() %>
</span>
<% end %>
<%= if @ammo_group.price_paid do %>
<span class="rounded-lg title text-lg">
<%= gettext("Price paid:") %>
<%= gettext("$%{amount}",
amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2)
) %>
</span>
<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 %>
<%= if @show_container and @ammo_group.container do %>
<span class="rounded-lg title text-lg">
<%= gettext("Container:") %>
<.link
navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)}
class="link"
>
<%= @ammo_group.container.name %>
</.link>
</span>
<% end %>
</div>
<%= if assigns |> Map.has_key?(:inner_block) do %>
<div class="mt-4 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -3,7 +3,9 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
A component that displays a list of ammo groups A component that displays a list of ammo groups
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Repo} alias Cannery.{Accounts.User, Ammo.AmmoGroup, ComparableDate}
alias Cannery.{ActivityLog, Ammo, Containers}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -13,6 +15,7 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:ammo_groups) => [AmmoGroup.t()], required(:ammo_groups) => [AmmoGroup.t()],
required(:show_used) => boolean(),
optional(:ammo_type) => Rendered.t(), optional(:ammo_type) => Rendered.t(),
optional(:range) => Rendered.t(), optional(:range) => Rendered.t(),
optional(:container) => Rendered.t(), optional(:container) => Rendered.t(),
@ -21,7 +24,11 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{id: _id, ammo_groups: _ammo_group, current_user: _current_user} = assigns, socket) do def update(
%{id: _id, ammo_groups: _ammo_group, current_user: _current_user, show_used: _show_used} =
assigns,
socket
) do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
@ -42,65 +49,75 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
ammo_type: ammo_type, ammo_type: ammo_type,
range: range, range: range,
container: container, container: container,
actions: actions actions: actions,
show_used: show_used
} }
} = socket } = socket
) do ) do
columns = columns =
if actions == [] do
[] []
else |> TableComponent.maybe_compose_columns(
[%{label: nil, key: :actions, sortable: false}] %{label: gettext("Actions"), key: :actions, sortable: false},
end actions != []
)
columns = [ |> TableComponent.maybe_compose_columns(%{
%{label: gettext("Purchased on"), key: :purchased_on}, label: gettext("Last used on"),
%{label: gettext("Last used on"), key: :used_up_on} | columns key: :used_up_on,
] type: ComparableDate
})
columns = |> TableComponent.maybe_compose_columns(%{
if container == [] do label: gettext("Purchased on"),
columns key: :purchased_on,
else type: ComparableDate
[%{label: gettext("Container"), key: :container} | columns] })
end |> TableComponent.maybe_compose_columns(
%{label: gettext("Container"), key: :container},
columns = container != []
if range == [] do )
columns |> TableComponent.maybe_compose_columns(
else %{label: gettext("Range"), key: :range},
[%{label: gettext("Range"), key: :range} | columns] range != []
end )
|> TableComponent.maybe_compose_columns(%{label: gettext("CPR"), key: :cpr})
columns = [ |> TableComponent.maybe_compose_columns(%{label: gettext("Price paid"), key: :price_paid})
%{label: gettext("Count"), key: :count}, |> TableComponent.maybe_compose_columns(
%{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("% left"), key: :remaining},
%{label: gettext("Notes"), key: :notes} show_used
| columns )
] |> TableComponent.maybe_compose_columns(
%{label: gettext("Original Count"), key: :original_count},
show_used
)
|> TableComponent.maybe_compose_columns(%{
label: if(show_used, do: gettext("Current Count"), else: gettext("Count")),
key: :count
})
|> TableComponent.maybe_compose_columns(
%{label: gettext("Ammo type"), key: :ammo_type},
ammo_type != []
)
columns = containers =
if ammo_type == [] do ammo_groups
columns |> Enum.map(fn %{container_id: container_id} -> container_id end)
else |> Containers.get_containers(current_user)
[%{label: gettext("Ammo type"), key: :ammo_type} | columns]
end
extra_data = %{ extra_data = %{
current_user: current_user, current_user: current_user,
ammo_type: ammo_type, ammo_type: ammo_type,
columns: columns, columns: columns,
container: container, container: container,
containers: containers,
original_counts: Ammo.get_original_counts(ammo_groups, current_user),
cprs: Ammo.get_cprs(ammo_groups, current_user),
last_used_dates: ActivityLog.get_last_used_dates(ammo_groups, current_user),
percentages_remaining: Ammo.get_percentages_remaining(ammo_groups, current_user),
actions: actions, actions: actions,
range: range range: range
} }
rows = rows =
ammo_groups ammo_groups
|> Repo.preload([:ammo_type, :container])
|> Enum.map(fn ammo_group -> |> Enum.map(fn ammo_group ->
ammo_group |> get_row_data_for_ammo_group(extra_data) ammo_group |> get_row_data_for_ammo_group(extra_data)
end) end)
@ -112,20 +129,13 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} />
module={CanneryWeb.Components.TableComponent}
id={"table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div> </div>
""" """
end end
@spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map() @spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map()
defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do
ammo_group = ammo_group |> Repo.preload([:ammo_type, :container])
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
{key, get_value_for_key(key, ammo_group, additional_data)} {key, get_value_for_key(key, ammo_group, additional_data)}
@ -147,33 +157,27 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
"""} """}
end end
defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil} defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data),
do: {0, gettext("No cost information")}
defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), 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)) do: {price_paid, gettext("$%{amount}", amount: display_currency(price_paid))}
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on}, _additional_data) do
assigns = %{purchased_on: purchased_on}
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do
{purchased_on, {purchased_on,
~H""" ~H"""
<%= @purchased_on |> display_date() %> <.date id={"#{@id}-purchased-on"} date={@purchased_on} />
"""} """}
end end
defp get_value_for_key(:used_up_on, ammo_group, _additional_data) do defp get_value_for_key(:used_up_on, %{id: ammo_group_id}, %{last_used_dates: last_used_dates}) do
last_shot_group_date = last_used_date = last_used_dates |> Map.get(ammo_group_id)
case ammo_group |> Ammo.get_last_used_shot_group() do assigns = %{id: ammo_group_id, last_used_date: last_used_date}
%{date: last_shot_group_date} -> last_shot_group_date
_no_shot_groups -> nil
end
assigns = %{last_shot_group_date: last_shot_group_date} {last_used_date,
{last_shot_group_date,
~H""" ~H"""
<%= if @last_shot_group_date do %> <%= if @last_used_date do %>
<%= @last_shot_group_date |> display_date() %> <.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %> <% else %>
<%= gettext("Never used") %> <%= gettext("Never used") %>
<% end %> <% end %>
@ -189,8 +193,14 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
"""} """}
end end
defp get_value_for_key(:remaining, ammo_group, _additional_data), defp get_value_for_key(
do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining()) :remaining,
%{id: ammo_group_id},
%{percentages_remaining: percentages_remaining}
) do
percentage = Map.fetch!(percentages_remaining, ammo_group_id)
{percentage, gettext("%{percentage}%", percentage: percentage)}
end
defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do
assigns = %{actions: actions, ammo_group: ammo_group} assigns = %{actions: actions, ammo_group: ammo_group}
@ -204,31 +214,44 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
defp get_value_for_key( defp get_value_for_key(
:container, :container,
%{container: %{name: container_name}} = ammo_group, %{container_id: container_id} = ammo_group,
%{container: container} %{container: container_block, containers: containers}
) do ) do
assigns = %{container: container, ammo_group: ammo_group} container = %{name: container_name} = Map.fetch!(containers, container_id)
assigns = %{
container: container,
container_block: container_block,
ammo_group: ammo_group
}
{container_name, {container_name,
~H""" ~H"""
<%= render_slot(@container, @ammo_group) %> <%= render_slot(@container_block, {@ammo_group, @container}) %>
"""} """}
end end
defp get_value_for_key(:original_count, ammo_group, _additional_data), defp get_value_for_key(
do: ammo_group |> Ammo.get_original_count() :original_count,
%{id: ammo_group_id},
%{original_counts: original_counts}
) do
Map.fetch!(original_counts, ammo_group_id)
end
defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data),
do: gettext("No cost information") do: {0, gettext("No cost information")}
defp get_value_for_key(:cpr, ammo_group, _additional_data) do defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do
gettext("$%{amount}", amount = Map.fetch!(cprs, ammo_group_id)
amount: ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2) {amount, gettext("$%{amount}", amount: display_currency(amount))}
)
end end
defp get_value_for_key(:count, %{count: count}, _additional_data), defp get_value_for_key(:count, %{count: count}, _additional_data),
do: if(count == 0, do: gettext("Empty"), else: count) do: if(count == 0, do: {0, gettext("Empty")}, else: count)
defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key) defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end end

View File

@ -3,7 +3,8 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
A component that displays a list of ammo type A component that displays a list of ammo type
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType} alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.AmmoType}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -12,6 +13,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
%{ %{
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:type) => AmmoType.type() | nil,
optional(:show_used) => boolean(), optional(:show_used) => boolean(),
optional(:ammo_types) => [AmmoType.t()], optional(:ammo_types) => [AmmoType.t()],
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
@ -24,6 +26,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:show_used, fn -> false end) |> assign_new(:show_used, fn -> false end)
|> assign_new(:type, fn -> :all end)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_ammo_types() |> display_ammo_types()
@ -36,92 +39,146 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
ammo_types: ammo_types, ammo_types: ammo_types,
current_user: current_user, current_user: current_user,
show_used: show_used, show_used: show_used,
type: type,
actions: actions actions: actions
} }
} = socket } = socket
) do ) do
columns = filtered_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("Cartridge"), key: :cartridge, type: :string},
%{label: gettext("Caliber"), key: :caliber, type: :string}, %{
%{label: gettext("Case material"), key: :case_material, type: :string}, label: if(type == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")),
key: :caliber,
type: :string
},
%{label: gettext("Unfired shell length"), key: :unfired_length, type: :string},
%{label: gettext("Brass height"), key: :brass_height, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Grains"), key: :grains, type: :string},
%{label: gettext("Bullet type"), key: :bullet_type, type: :string},
%{
label: if(type == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")),
key: :bullet_core,
type: :string
},
%{label: gettext("Jacket type"), key: :jacket_type, type: :string}, %{label: gettext("Jacket type"), key: :jacket_type, type: :string},
%{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, %{label: gettext("Case material"), key: :case_material, type: :string},
%{label: gettext("Wadding"), key: :wadding, type: :string},
%{label: gettext("Shot type"), key: :shot_type, type: :string},
%{label: gettext("Shot material"), key: :shot_material, type: :string},
%{label: gettext("Shot size"), key: :shot_size, type: :string},
%{label: gettext("Load grains"), key: :load_grains, type: :string},
%{label: gettext("Shot charge weight"), key: :shot_charge_weight, type: :string},
%{label: gettext("Powder type"), key: :powder_type, type: :string}, %{label: gettext("Powder type"), key: :powder_type, type: :string},
%{ %{
label: gettext("Powder grains per charge"), label: gettext("Powder grains per charge"),
key: :powder_grains_per_charge, key: :powder_grains_per_charge,
type: :string type: :string
}, },
%{label: gettext("Grains"), key: :grains, type: :string},
%{label: gettext("Pressure"), key: :pressure, type: :string}, %{label: gettext("Pressure"), key: :pressure, type: :string},
%{label: gettext("Dram equivalent"), key: :dram_equivalent, type: :string},
%{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string},
%{label: gettext("Primer type"), key: :primer_type, type: :string}, %{label: gettext("Primer type"), key: :primer_type, type: :string},
%{label: gettext("Firing type"), key: :firing_type, type: :string}, %{label: gettext("Firing type"), key: :firing_type, type: :string},
%{label: gettext("Tracer"), key: :tracer, type: :boolean}, %{label: gettext("Tracer"), key: :tracer, type: :atom},
%{label: gettext("Incendiary"), key: :incendiary, type: :boolean}, %{label: gettext("Incendiary"), key: :incendiary, type: :atom},
%{label: gettext("Blank"), key: :blank, type: :boolean}, %{label: gettext("Blank"), key: :blank, type: :atom},
%{label: gettext("Corrosive"), key: :corrosive, type: :boolean}, %{label: gettext("Corrosive"), key: :corrosive, type: :atom},
%{label: gettext("Manufacturer"), key: :manufacturer, type: :string}, %{label: gettext("Manufacturer"), key: :manufacturer, type: :string}
%{label: gettext("UPC"), key: "upc", type: :string}
] ]
|> Enum.filter(fn %{key: key, type: type} -> |> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults # remove columns if all values match defaults
default_value = if type == :boolean, do: false, else: nil default_value = if type == :atom, do: false, else: nil
ammo_types ammo_types
|> Enum.any?(fn ammo_type -> |> Enum.any?(fn ammo_type -> Map.get(ammo_type, key, default_value) != default_value end)
not (ammo_type |> Map.get(key) == default_value)
end) end)
end)
|> Kernel.++([ columns =
%{label: gettext("Rounds"), key: :round_count, type: :round_count} [%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}]
]) |> TableComponent.maybe_compose_columns(%{
|> Kernel.++( label: gettext("Average CPR"),
if show_used do key: :avg_price_paid,
[ type: :avg_price_paid
})
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Total ever packs"),
key: :historical_pack_count,
type: :historical_pack_count
},
show_used
)
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Used packs"),
key: :used_pack_count,
type: :used_pack_count
},
show_used
)
|> TableComponent.maybe_compose_columns(%{
label: gettext("Packs"),
key: :ammo_count,
type: :ammo_count
})
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Total ever rounds"),
key: :historical_round_count,
type: :historical_round_count
},
show_used
)
|> TableComponent.maybe_compose_columns(
%{ %{
label: gettext("Used rounds"), label: gettext("Used rounds"),
key: :used_round_count, key: :used_round_count,
type: :used_round_count type: :used_round_count
}, },
%{ show_used
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}]) |> TableComponent.maybe_compose_columns(%{
|> Kernel.++( label: gettext("Rounds"),
key: :round_count,
type: :round_count
})
|> TableComponent.maybe_compose_columns(filtered_columns)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Type"), key: :type, type: :atom},
type in [:all, nil]
)
|> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name})
round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user)
packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do if show_used do
[ [
%{ ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user),
label: gettext("Used packs"), ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user),
key: :used_ammo_count, ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true),
type: :used_ammo_count ammo_types |> Ammo.get_used_ammo_groups_count_for_types(current_user)
},
%{
label: gettext("Total ever packs"),
key: :historical_ammo_count,
type: :historical_ammo_count
}
] ]
else else
[] [nil, nil, nil, nil]
end 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} extra_data = %{
actions: actions,
current_user: current_user,
used_counts: used_counts,
round_counts: round_counts,
historical_round_counts: historical_round_counts,
packs_count: packs_count,
used_pack_counts: used_pack_counts,
historical_pack_counts: historical_pack_counts,
average_costs: average_costs
}
rows = rows =
ammo_types ammo_types
@ -136,12 +193,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} />
module={CanneryWeb.Components.TableComponent}
id={"table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div> </div>
""" """
end end
@ -153,46 +205,72 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
end) end)
end end
defp get_ammo_type_value(:boolean, key, ammo_type, _other_data), defp get_ammo_type_value(:atom, key, ammo_type, _other_data),
do: ammo_type |> Map.get(key) |> humanize() do: ammo_type |> Map.get(key) |> humanize()
defp get_ammo_type_value(:round_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(:round_count, _key, %{id: ammo_type_id}, %{round_counts: round_counts}),
do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) do: Map.get(round_counts, ammo_type_id, 0)
defp get_ammo_type_value(:historical_round_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(
do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) :historical_round_count,
_key,
%{id: ammo_type_id},
%{historical_round_counts: historical_round_counts}
) do
Map.get(historical_round_counts, ammo_type_id, 0)
end
defp get_ammo_type_value(:used_round_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(
do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) :used_round_count,
_key,
%{id: ammo_type_id},
%{used_counts: used_counts}
) do
Map.get(used_counts, ammo_type_id, 0)
end
defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) :historical_pack_count,
_key,
%{id: ammo_type_id},
%{historical_pack_counts: historical_pack_counts}
) do
Map.get(historical_pack_counts, ammo_type_id, 0)
end
defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(
do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user) :used_pack_count,
_key,
%{id: ammo_type_id},
%{used_pack_counts: used_pack_counts}
) do
Map.get(used_pack_counts, ammo_type_id, 0)
end
defp get_ammo_type_value(:ammo_count, _key, ammo_type, %{current_user: current_user}), defp get_ammo_type_value(:ammo_count, _key, %{id: ammo_type_id}, %{packs_count: packs_count}),
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user) do: Map.get(packs_count, ammo_type_id)
defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, %{current_user: current_user}) do defp get_ammo_type_value(
case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do :avg_price_paid,
nil -> gettext("No cost information") _key,
count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2)) %{id: ammo_type_id},
%{average_costs: average_costs}
) do
case Map.get(average_costs, ammo_type_id) do
nil -> {0, gettext("No cost information")}
count -> {count, gettext("$%{amount}", amount: display_currency(count))}
end end
end end
defp get_ammo_type_value(:name, _key, ammo_type, _other_data) do defp get_ammo_type_value(:name, _key, %{name: ammo_type_name} = ammo_type, _other_data) do
assigns = %{ammo_type: ammo_type} assigns = %{ammo_type: ammo_type}
{ammo_type_name,
~H""" ~H"""
<.link <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link">
navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
class="link"
data-qa={"view-name-#{@ammo_type.id}"}
>
<%= @ammo_type.name %> <%= @ammo_type.name %>
</.link> </.link>
""" """}
end end
defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do
@ -206,4 +284,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil 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) defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end end

View File

@ -1,87 +0,0 @@
defmodule CanneryWeb.Components.ContainerCard do
@moduledoc """
Display card for a container
"""
use CanneryWeb, :component
import CanneryWeb.Components.TagCard
alias Cannery.{Containers, Containers.Container, Repo}
alias CanneryWeb.Endpoint
alias Phoenix.LiveView.Rendered
attr :container, Container, required: true
slot(:tag_actions)
slot(:inner_block)
@spec container_card(assigns :: map()) :: Rendered.t()
def container_card(%{container: container} = assigns) do
assigns =
assigns
|> assign(container: container |> Repo.preload([:tags, :ammo_groups]))
|> assign_new(:tag_actions, fn -> [] end)
~H"""
<div
id={"container-#{@container.id}"}
class="overflow-hidden max-w-full mx-4 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">
<.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link">
<h1 class="px-4 py-2 rounded-lg title text-xl">
<%= @container.name %>
</h1>
</.link>
<%= if @container.desc do %>
<span class="rounded-lg title text-lg">
<%= gettext("Description:") %>
<%= @container.desc %>
</span>
<% end %>
<span class="rounded-lg title text-lg">
<%= gettext("Type:") %>
<%= @container.type %>
</span>
<%= if @container.location do %>
<span class="rounded-lg title text-lg">
<%= gettext("Location:") %>
<%= @container.location %>
</span>
<% end %>
<%= unless @container.ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg">
<%= gettext("Packs:") %>
<%= @container |> Containers.get_container_ammo_group_count!() %>
</span>
<span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %>
<%= @container |> Containers.get_container_rounds!() %>
</span>
<% end %>
<div class="flex flex-wrap justify-center items-center">
<%= unless @container.tags |> Enum.empty?() do %>
<%= for tag <- @container.tags do %>
<.simple_tag_card tag={tag} />
<% end %>
<% end %>
<%= render_slot(@tag_actions) %>
</div>
</div>
<%= if assigns |> Map.has_key?(:inner_block) do %>
<div class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -3,8 +3,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
A component that displays a list of containers A component that displays a list of containers
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Containers, Containers.Container, Repo} alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias CanneryWeb.Components.TagCard
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -46,11 +45,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
%{label: gettext("Name"), key: :name, type: :string}, %{label: gettext("Name"), key: :name, type: :string},
%{label: gettext("Description"), key: :desc, type: :string}, %{label: gettext("Description"), key: :desc, type: :string},
%{label: gettext("Location"), key: :location, type: :string}, %{label: gettext("Location"), key: :location, type: :string},
%{label: gettext("Type"), key: :type, 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} -> |> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults # remove columns if all values match defaults
@ -65,11 +60,19 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
type in [:tags, :actions] or not (container |> Map.get(key) == default_value) type in [:tags, :actions] or not (container |> Map.get(key) == default_value)
end) end)
end) end)
|> Enum.concat([
%{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :integer},
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
])
extra_data = %{ extra_data = %{
current_user: current_user, current_user: current_user,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions actions: actions,
pack_count: Ammo.get_ammo_groups_count_for_containers(containers, current_user),
round_count: Ammo.get_round_count_for_containers(containers, current_user)
} }
rows = rows =
@ -101,8 +104,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
@spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map() @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 defp get_row_data_for_container(container, columns, extra_data) do
container = container |> Repo.preload([:ammo_groups, :tags])
columns columns
|> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end) |> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end)
end end
@ -121,25 +122,27 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
"""} """}
end end
defp get_value_for_key(:packs, container, _extra_data) do defp get_value_for_key(:packs, %{id: container_id}, %{pack_count: pack_count}) do
container |> Containers.get_container_ammo_group_count!() pack_count |> Map.get(container_id, 0)
end end
defp get_value_for_key(:rounds, container, _extra_data) do defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do
container |> Containers.get_container_rounds!() round_count |> Map.get(container_id, 0)
end end
defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do
assigns = %{tag_actions: tag_actions, container: container} assigns = %{tag_actions: tag_actions, container: container}
{container.tags |> Enum.map(fn %{name: name} -> name end), tag_names =
container.tags
|> Enum.map(fn %{name: name} -> name end)
|> Enum.sort()
|> Enum.join(" ")
{tag_names,
~H""" ~H"""
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<%= unless @container.tags |> Enum.empty?() do %> <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= for tag <- @container.tags do %>
<TagCard.simple_tag_card tag={tag} />
<% end %>
<% end %>
<%= render_slot(@tag_actions, @container) %> <%= render_slot(@tag_actions, @container) %>
</div> </div>

View File

@ -0,0 +1,149 @@
defmodule CanneryWeb.CoreComponents do
@moduledoc """
Provides core UI components.
"""
use Phoenix.Component
import CanneryWeb.{Gettext, ViewHelpers}
alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.AmmoGroup}
alias Cannery.{Containers.Container, Containers.Tag}
alias CanneryWeb.{Endpoint, HomeLive}
alias CanneryWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.{JS, Rendered}
embed_templates "core_components/*"
attr :title_content, :string, default: nil
attr :current_user, User, default: nil
def topbar(assigns)
attr :return_to, :string, required: true
slot(:inner_block)
@doc """
Renders a live component inside a modal.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
<.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new}
title={@page_title}
action={@live_action}
return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
<%= schema.singular %>: @<%= schema.singular %>
/>
</.modal>
"""
def modal(assigns)
defp hide_modal(js \\ %JS{}) do
js
|> JS.hide(to: "#modal", transition: "fade-out")
|> JS.hide(to: "#modal-bg", transition: "fade-out")
|> JS.hide(to: "#modal-content", transition: "fade-out-scale")
end
attr :action, :string, required: true
attr :value, :boolean, required: true
attr :id, :string, default: nil
slot(:inner_block)
@doc """
A toggle button element that can be directed to a liveview or a
live_component's `handle_event/3`.
## Examples
<.toggle_button action="my_liveview_action" value={@some_value}>
<span>Toggle me!</span>
</.toggle_button>
<.toggle_button action="my_live_component_action" target={@myself} value={@some_value}>
<span>Whatever you want</span>
</.toggle_button>
"""
def toggle_button(assigns)
attr :container, Container, required: true
attr :current_user, User, required: true
slot(:tag_actions)
slot(:inner_block)
@spec container_card(assigns :: map()) :: Rendered.t()
def container_card(assigns)
attr :tag, Tag, required: true
slot(:inner_block, required: true)
def tag_card(assigns)
attr :tag, Tag, required: true
def simple_tag_card(assigns)
attr :ammo_group, AmmoGroup, required: true
attr :current_user, User, required: true
attr :original_count, :integer, default: nil
attr :cpr, :integer, default: nil
attr :last_used_date, Date, default: nil
attr :container, Container, default: nil
slot(:inner_block)
def ammo_group_card(assigns)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
attr :user, User, required: true
slot(:inner_block, required: true)
def user_card(assigns)
attr :invite, Invite, required: true
attr :use_count, :integer, default: nil
attr :current_user, User, required: true
slot(:inner_block)
slot(:code_actions)
def invite_card(assigns)
attr :content, :string, required: true
attr :filename, :string, default: "qrcode", doc: "filename without .png extension"
attr :image_class, :string, default: "w-64 h-max"
attr :width, :integer, default: 384, doc: "width of png to generate"
@doc """
Creates a downloadable QR Code element
"""
def qr_code(assigns)
attr :id, :string, required: true
attr :date, :any, required: true, doc: "A `Date` struct or nil"
@doc """
Phoenix.Component for a <date> element that renders the Date in the user's
local timezone
"""
def date(assigns)
attr :id, :string, required: true
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """
Phoenix.Component for a <time> element that renders the naivedatetime in the
user's local timezone
"""
def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
end
defp cast_datetime(_datetime), do: ""
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
<.link
patch={@return_to}
id="modal-bg"
class="fade-in fixed z-10 left-0 top-0
w-full h-full overflow-hidden
p-8 flex flex-col justify-center items-center cursor-auto"
style="background-color: rgba(0,0,0,0.4);"
phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
>
<span class="hidden"></span>
</.link>
<div
id="modal"
class="fixed z-10 left-0 top-0 pointer-events-none
w-full h-full overflow-hidden
p-4 sm:p-8 flex flex-col justify-center items-center"
>
<div
id="modal-content"
class="fade-in-scale w-full max-w-3xl relative
pointer-events-auto overflow-hidden
px-8 py-4 sm:py-8
flex flex-col justify-start items-center
bg-white border-2 rounded-lg"
>
<.link
patch={@return_to}
id="close"
class="absolute top-8 right-10
text-gray-500 hover:text-gray-800
transition-all duration-500 ease-in-out"
phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
>
<i class="fa-fw fa-lg fas fa-times"></i>
</.link>
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
<%= render_slot(@inner_block) %>
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
<a href={qr_code_image(@content)} download={@filename <> ".png"}>
<img class={@image_class} alt={@filename} src={qr_code_image(@content)} />
</a>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,130 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
<h1 class="inline hover:underline">Cannery</h1>
</.link>
<%= if @title_content do %>
<span class="mx-2 my-1">
|
</span>
<%= @title_content %>
<% end %>
</div>
<hr class="mb-2 sm:hidden hr-light" />
<ul class="flex flex-row flex-wrap justify-center items-center
text-lg text-white text-ellipsis">
<%= if @current_user do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.tag_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Tags") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.container_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_type_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_group_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Range") %>
</.link>
</li>
<li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Invites") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_settings_path(Endpoint, :edit)}
class="text-white hover:underline truncate"
>
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
aria-label={gettext("Log out")}
>
<i class="fas fa-sign-out-alt"></i>
</.link>
</li>
<li
:if={
@current_user |> Accounts.is_already_admin?() and
function_exported?(Routes, :live_dashboard_path, 2)
}
class="mx-2 my-1"
>
<.link
navigate={Routes.live_dashboard_path(Endpoint, :home)}
class="text-white hover:underline"
aria-label={gettext("Live Dashboard")}
>
<i class="fas fa-gauge"></i>
</.link>
</li>
<% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link
href={Routes.user_registration_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %>
</.link>
</li>
<% end %>
</ul>
</div>
</nav>

View File

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

View File

@ -1,56 +0,0 @@
defmodule CanneryWeb.Components.InviteCard do
@moduledoc """
Display card for an invite
"""
use CanneryWeb, :component
alias Cannery.Invites.Invite
alias CanneryWeb.Endpoint
attr :invite, Invite, required: true
slot(:inner_block)
slot(:code_actions)
def invite_card(assigns) do
assigns = assigns |> assign_new(:code_actions, fn -> [] end)
~H"""
<div
id={"invite-#{@invite.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="title text-xl">
<%= @invite.name %>
</h1>
<%= if @invite.disabled_at |> is_nil() do %>
<h2 class="title text-md">
<%= gettext("Uses Left:") %>
<%= @invite.uses_left || "Unlimited" %>
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("Invite Disabled") %>
</h2>
<% end %>
<div class="flex flex-row flex-wrap justify-center items-center">
<code
id={"code-#{@invite.id}"}
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
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>
"""
end
end

View File

@ -6,6 +6,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container} alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container}
alias CanneryWeb.Endpoint alias CanneryWeb.Endpoint
alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
@ -51,10 +52,9 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
|> case do |> case do
{:ok, _ammo_group} -> {:ok, _ammo_group} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset) socket |> assign(changeset: changeset)
end end
@ -64,10 +64,10 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@impl true @impl true
def render(%{containers: containers} = assigns) do def render(%{containers: containers} = assigns) do
columns = [ columns = [
%{label: gettext("Container"), key: "name"}, %{label: gettext("Container"), key: :name},
%{label: gettext("Type"), key: "type"}, %{label: gettext("Type"), key: :type},
%{label: gettext("Location"), key: "location"}, %{label: gettext("Location"), key: :location},
%{label: nil, key: "actions", sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
rows = containers |> get_rows_for_containers(assigns, columns) rows = containers |> get_rows_for_containers(assigns, columns)
@ -110,8 +110,8 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
end) end)
end end
@spec get_row_value_by_key(String.t(), Container.t(), map()) :: any() @spec get_row_value_by_key(atom(), Container.t(), map()) :: any()
defp get_row_value_by_key("actions", container, assigns) do defp get_row_value_by_key(:actions, container, assigns) do
assigns = assigns |> Map.put(:container, container) assigns = assigns |> Map.put(:container, container)
~H""" ~H"""
@ -129,6 +129,5 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
""" """
end end
defp get_row_value_by_key(key, container, _assigns), defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key)
do: container |> Map.get(key |> String.to_existing_atom())
end end

View File

@ -3,7 +3,7 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
A component that displays a list of shot groups A component that displays a list of shot groups
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo} alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, ComparableDate}
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -41,11 +41,16 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
%{label: gettext("Ammo"), key: :name}, %{label: gettext("Ammo"), key: :name},
%{label: gettext("Rounds shot"), key: :count}, %{label: gettext("Rounds shot"), key: :count},
%{label: gettext("Notes"), key: :notes}, %{label: gettext("Notes"), key: :notes},
%{label: gettext("Date"), key: :date}, %{label: gettext("Date"), key: :date, type: ComparableDate},
%{label: nil, key: :actions, sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
extra_data = %{current_user: current_user, actions: actions} ammo_groups =
shot_groups
|> Enum.map(fn %{ammo_group_id: ammo_group_id} -> ammo_group_id end)
|> Ammo.get_ammo_groups(current_user)
extra_data = %{current_user: current_user, actions: actions, ammo_groups: ammo_groups}
rows = rows =
shot_groups shot_groups
@ -79,31 +84,29 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do
@spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) :: @spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) ::
map() map()
defp get_row_data_for_shot_group(shot_group, columns, extra_data) do defp get_row_data_for_shot_group(shot_group, columns, extra_data) do
shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type)
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
{key, get_row_value(key, shot_group, extra_data)} {key, get_row_value(key, shot_group, extra_data)}
end) end)
end end
defp get_row_value( defp get_row_value(:name, %{ammo_group_id: ammo_group_id}, %{ammo_groups: ammo_groups}) do
:name, assigns = %{ammo_group: ammo_group = Map.fetch!(ammo_groups, ammo_group_id)}
%{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""" {ammo_group.ammo_type.name,
~H"""
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link"> <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link">
<%= @ammo_type_name %> <%= @ammo_group.ammo_type.name %>
</.link> </.link>
""" """}
{ammo_type_name, name_block}
end end
defp get_row_value(:date, %{date: date}, _extra_data), do: date |> display_date() defp get_row_value(:date, %{date: date} = assigns, _extra_data) do
{date,
~H"""
<.date id={"#{@id}-date"} date={@date} />
"""}
end
defp get_row_value(:actions, shot_group, %{actions: actions}) do defp get_row_value(:actions, shot_group, %{actions: actions}) do
assigns = %{actions: actions, shot_group: shot_group} assigns = %{actions: actions, shot_group: shot_group}

View File

@ -33,7 +33,8 @@ defmodule CanneryWeb.Components.TableComponent do
optional(:class) => String.t(), optional(:class) => String.t(),
optional(:row_class) => String.t(), optional(:row_class) => String.t(),
optional(:alternate_row_class) => String.t(), optional(:alternate_row_class) => String.t(),
optional(:sortable) => false optional(:sortable) => false,
optional(:type) => module()
}), }),
required(:rows) => required(:rows) =>
list(%{ list(%{
@ -60,7 +61,8 @@ defmodule CanneryWeb.Components.TableComponent do
:asc :asc
end end
rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode) type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type)
socket = socket =
socket socket
@ -68,6 +70,7 @@ defmodule CanneryWeb.Components.TableComponent do
|> assign( |> assign(
columns: columns, columns: columns,
rows: rows, rows: rows,
key: initial_key,
last_sort_key: initial_key, last_sort_key: initial_key,
sort_mode: initial_sort_mode sort_mode: initial_sort_mode
) )
@ -81,7 +84,14 @@ defmodule CanneryWeb.Components.TableComponent do
def handle_event( def handle_event(
"sort_by", "sort_by",
%{"sort-key" => key}, %{"sort-key" => key},
%{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket %{
assigns: %{
columns: columns,
rows: rows,
last_sort_key: last_sort_key,
sort_mode: sort_mode
}
} = socket
) do ) do
key = key |> String.to_existing_atom() key = key |> String.to_existing_atom()
@ -92,11 +102,28 @@ defmodule CanneryWeb.Components.TableComponent do
{_new_sort_key, _last_sort_mode} -> :asc {_new_sort_key, _last_sort_mode} -> :asc
end end
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) type =
columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type)
{:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)}
end end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type)
when type in [Date, DateTime] do
rows
|> Enum.sort_by(
fn row ->
case row |> Map.get(key) do
{custom_sort_key, _value} -> custom_sort_key
value -> value
end
end,
{sort_mode, type}
)
end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do
rows rows
|> Enum.sort_by( |> Enum.sort_by(
fn row -> fn row ->
@ -108,4 +135,25 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode sort_mode
) )
end end
@doc """
Conditionally composes elements into the columns list, supports maps and
lists. Works tail to front in order for efficiency
iex> []
...> |> maybe_compose_columns(%{label: "Column 3"}, true)
...> |> maybe_compose_columns(%{label: "Column 2"}, false)
...> |> maybe_compose_columns(%{label: "Column 1"})
[%{label: "Column 1"}, %{label: "Column 3"}]
"""
@spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list()
@spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list()
def maybe_compose_columns(columns, element_or_elements, add? \\ true)
def maybe_compose_columns(columns, elements, true) when is_list(elements),
do: Enum.concat(elements, columns)
def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns]
def maybe_compose_columns(columns, _element_or_elements, false), do: columns
end end

View File

@ -4,7 +4,7 @@
<tr> <tr>
<%= for %{key: key, label: label} = column <- @columns do %> <%= for %{key: key, label: label} = column <- @columns do %>
<%= if column |> Map.get(:sortable, true) do %> <%= if column |> Map.get(:sortable, true) do %>
<th class={"p-2 #{column[:class]}"}> <th class={["p-2", column[:class]]}>
<span <span
class="cursor-pointer flex justify-center items-center space-x-2" class="cursor-pointer flex justify-center items-center space-x-2"
phx-click="sort_by" phx-click="sort_by"
@ -26,7 +26,7 @@
</span> </span>
</th> </th>
<% else %> <% else %>
<th class={"p-2 cursor-not-allowed #{column[:class]}"}> <th class={["p-2 cursor-not-allowed", column[:class]]}>
<%= label %> <%= label %>
</th> </th>
<% end %> <% end %>
@ -34,10 +34,11 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<%= for {values, i} <- @rows |> Enum.with_index() do %> <tr
<tr class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}> :for={{values, i} <- @rows |> Enum.with_index()}
<%= for %{key: key} = value <- @columns do %> class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}
<td class={"p-2 #{value[:class]}"}> >
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %> <%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %> <% {_custom_sort_value, value} -> %>
<%= value %> <%= value %>
@ -45,9 +46,7 @@
<%= value %> <%= value %>
<% end %> <% end %>
</td> </td>
<% end %>
</tr> </tr>
<% end %>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,38 +0,0 @@
defmodule CanneryWeb.Components.TagCard do
@moduledoc """
Display card for a tag
"""
use CanneryWeb, :component
alias Cannery.Tags.Tag
attr :tag, Tag, required: true
slot(:inner_block, required: true)
def tag_card(assigns) do
~H"""
<div
id={"tag-#{@tag.id}"}
class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.simple_tag_card tag={@tag} />
<%= render_slot(@inner_block) %>
</div>
"""
end
attr :tag, Tag, required: true
def simple_tag_card(assigns) do
~H"""
<h1
class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
>
<%= @tag.name %>
</h1>
"""
end
end

View File

@ -1,146 +0,0 @@
defmodule CanneryWeb.Components.Topbar do
@moduledoc """
Component that renders a topbar with user functions/links
"""
use CanneryWeb, :component
alias Cannery.Accounts
alias CanneryWeb.{Endpoint, HomeLive}
def topbar(assigns) do
assigns =
%{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns)
~H"""
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
<h1 class="inline hover:underline">Cannery</h1>
</.link>
<%= if @title_content do %>
<span class="mx-2 my-1">
|
</span>
<%= @title_content %>
<% end %>
</div>
<hr class="mb-2 sm:hidden hr-light" />
<ul class="flex flex-row flex-wrap justify-center items-center
text-lg text-white text-ellipsis">
<%= if @current_user do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.tag_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Tags") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.container_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_type_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_group_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Range") %>
</.link>
</li>
<%= if @current_user.role == :admin do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Invites") %>
</.link>
</li>
<% end %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_settings_path(Endpoint, :edit)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
>
<i class="fas fa-sign-out-alt"></i>
</.link>
</li>
<%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.live_dashboard_path(Endpoint, :home)}
class="text-primary-600 text-white hover:underline"
>
<i class="fas fa-gauge"></i>
</.link>
</li>
<% end %>
<% else %>
<%= if Accounts.allow_registration?() do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_registration_path(Endpoint, :new)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %>
</.link>
</li>
<% end %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_session_path(Endpoint, :new)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %>
</.link>
</li>
<% end %>
</ul>
</div>
</nav>
"""
end
end

View File

@ -1,47 +0,0 @@
defmodule CanneryWeb.Components.UserCard do
@moduledoc """
Display card for a user
"""
use CanneryWeb, :component
alias Cannery.Accounts.User
attr :user, User, required: true
slot(:inner_block, required: true)
def user_card(assigns) do
~H"""
<div
id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %>
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<p>
<%= if @user.confirmed_at |> is_nil() do %>
Email unconfirmed
<% else %>
User was confirmed at <%= @user.confirmed_at |> display_datetime() %>
<% end %>
</p>
<p>
<%= gettext("User registered on") %>
<%= @user.inserted_at |> display_datetime() %>
</p>
</h3>
<%= if @inner_block do %>
<div class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -3,51 +3,58 @@ defmodule CanneryWeb.ExportController do
alias Cannery.{ActivityLog, Ammo, Containers} alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
ammo_types = ammo_types = Ammo.list_ammo_types(current_user, :all)
Ammo.list_ammo_types(current_user) used_counts = ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user)
|> Enum.map(fn ammo_type -> round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user)
average_cost = ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) ammo_group_counts = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user)
round_count = ammo_type |> Ammo.get_round_count_for_ammo_type(current_user)
used_count = ammo_type |> Ammo.get_used_count_for_ammo_type(current_user)
ammo_group_count = ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true)
total_ammo_group_counts =
ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user)
ammo_types =
ammo_types
|> Enum.map(fn %{id: ammo_type_id} = ammo_type ->
ammo_type ammo_type
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"average_cost" => average_cost, "average_cost" => Map.get(average_costs, ammo_type_id),
"round_count" => round_count, "round_count" => Map.get(round_counts, ammo_type_id, 0),
"used_count" => used_count, "used_count" => Map.get(used_counts, ammo_type_id, 0),
"ammo_group_count" => ammo_group_count "ammo_group_count" => Map.get(ammo_group_counts, ammo_type_id, 0),
"total_ammo_group_count" => Map.get(total_ammo_group_counts, ammo_type_id, 0)
}) })
end) end)
ammo_groups = ammo_groups = Ammo.list_ammo_groups(nil, :all, current_user, true)
Ammo.list_ammo_groups(nil, true, current_user) used_counts = ammo_groups |> ActivityLog.get_used_counts(current_user)
|> Enum.map(fn ammo_group -> original_counts = ammo_groups |> Ammo.get_original_counts(current_user)
cpr = ammo_group |> Ammo.get_cpr() cprs = ammo_groups |> Ammo.get_cprs(current_user)
used_count = ammo_group |> Ammo.get_used_count() percentages_remaining = ammo_groups |> Ammo.get_percentages_remaining(current_user)
original_count = ammo_group |> Ammo.get_original_count()
percentage_remaining = ammo_group |> Ammo.get_percentage_remaining()
ammo_groups =
ammo_groups
|> Enum.map(fn %{id: ammo_group_id} = ammo_group ->
ammo_group ammo_group
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"used_count" => used_count, "used_count" => Map.get(used_counts, ammo_group_id),
"percentage_remaining" => percentage_remaining, "percentage_remaining" => Map.fetch!(percentages_remaining, ammo_group_id),
"original_count" => original_count, "original_count" => Map.get(original_counts, ammo_group_id),
"cpr" => cpr "cpr" => Map.get(cprs, ammo_group_id)
}) })
end) end)
shot_groups = ActivityLog.list_shot_groups(current_user) shot_groups = ActivityLog.list_shot_groups(:all, current_user)
containers = containers =
Containers.list_containers(current_user) Containers.list_containers(current_user)
|> Enum.map(fn container -> |> Enum.map(fn container ->
ammo_group_count = container |> Containers.get_container_ammo_group_count!() ammo_group_count = container |> Ammo.get_ammo_groups_count_for_container!(current_user)
round_count = container |> Containers.get_container_rounds!() round_count = container |> Ammo.get_round_count_for_container!(current_user)
container container
|> Jason.encode!() |> Jason.encode!()

View File

@ -1,4 +1,8 @@
defmodule CanneryWeb.HomeController do defmodule CanneryWeb.HomeController do
@moduledoc """
Controller for home page
"""
use CanneryWeb, :controller use CanneryWeb, :controller
def index(conn, _params) do def index(conn, _params) do

View File

@ -1,14 +1,13 @@
defmodule CanneryWeb.UserRegistrationController do defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias Cannery.{Accounts, Invites} alias Cannery.{Accounts, Accounts.Invites}
alias CanneryWeb.{Endpoint, HomeLive} alias CanneryWeb.{Endpoint, HomeLive}
alias Ecto.Changeset
def new(conn, %{"invite" => invite_token}) do def new(conn, %{"invite" => invite_token}) do
invite = Invites.get_invite_by_token(invite_token) if Invites.valid_invite_token?(invite_token) do
conn |> render_new(invite_token)
if invite do
conn |> render_new(invite)
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
@ -27,19 +26,17 @@ defmodule CanneryWeb.UserRegistrationController do
end end
# renders new user registration page # renders new user registration page
defp render_new(conn, invite \\ nil) do defp render_new(conn, invite_token \\ nil) do
render(conn, "new.html", render(conn, "new.html",
changeset: Accounts.change_user_registration(), changeset: Accounts.change_user_registration(),
invite: invite, invite_token: invite_token,
page_title: gettext("Register") page_title: gettext("Register")
) )
end end
def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
invite = Invites.get_invite_by_token(invite_token) if Invites.valid_invite_token?(invite_token) do
conn |> create_user(attrs, invite_token)
if invite do
conn |> create_user(attrs, invite)
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
@ -57,13 +54,9 @@ defmodule CanneryWeb.UserRegistrationController do
end end
end end
defp create_user(conn, %{"user" => user_params}, invite \\ nil) do defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do
case Accounts.register_user(user_params) do case Accounts.register_user(user_params, invite_token) do
{:ok, user} -> {:ok, user} ->
unless invite |> is_nil() do
invite |> Invites.use_invite!()
end
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
&Routes.user_confirmation_url(conn, :confirm, &1) &Routes.user_confirmation_url(conn, :confirm, &1)
@ -73,8 +66,13 @@ defmodule CanneryWeb.UserRegistrationController do
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|> redirect(to: Routes.user_session_path(Endpoint, :new)) |> redirect(to: Routes.user_session_path(Endpoint, :new))
{:error, %Ecto.Changeset{} = changeset} -> {:error, :invalid_token} ->
conn |> render("new.html", changeset: changeset, invite: invite) conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
{:error, %Changeset{} = changeset} ->
conn |> render("new.html", changeset: changeset, invite_token: invite_token)
end end
end end
end end

View File

@ -26,7 +26,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
socket = socket =
socket socket
|> assign(:ammo_group_create_limit, @ammo_group_create_limit) |> assign(:ammo_group_create_limit, @ammo_group_create_limit)
|> assign(:ammo_types, Ammo.list_ammo_types(current_user)) |> assign(:ammo_types, Ammo.list_ammo_types(current_user, :all))
|> assign_new(:containers, fn -> Containers.list_containers(current_user) end) |> assign_new(:containers, fn -> Containers.list_containers(current_user) end)
params = params =
@ -44,7 +44,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do
{:noreply, socket |> assign_changeset(ammo_group_params)} {:noreply, socket |> assign_changeset(ammo_group_params, :validate)}
end end
def handle_event( def handle_event(
@ -56,6 +56,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
end end
# HTML Helpers # HTML Helpers
@spec container_options([Container.t()]) :: [{String.t(), Container.id()}] @spec container_options([Container.t()]) :: [{String.t(), Container.id()}]
defp container_options(containers) do defp container_options(containers) do
containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end) containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end)
@ -70,35 +71,28 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket, %{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket,
ammo_group_params ammo_group_params,
changeset_action \\ nil
) do ) do
changeset_action = default_action =
cond do case action do
action in [:new, :clone] -> :insert create when create in [:new, :clone] -> :insert
action == :edit -> :update :edit -> :update
end end
changeset = changeset =
cond do case default_action do
action in [:new, :clone] -> :insert ->
ammo_type = ammo_type = maybe_get_ammo_type(ammo_group_params, user)
if ammo_group_params |> Map.has_key?("ammo_type_id"), container = maybe_get_container(ammo_group_params, user)
do: ammo_group_params |> Map.get("ammo_type_id") |> Ammo.get_ammo_type!(user),
else: nil
container =
if ammo_group_params |> Map.has_key?("container_id"),
do: ammo_group_params |> Map.get("container_id") |> Containers.get_container!(user),
else: nil
ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params) ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params)
action == :edit -> :update ->
ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user) ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user)
end end
changeset = changeset =
case changeset |> Changeset.apply_action(changeset_action) do case changeset |> Changeset.apply_action(changeset_action || default_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
@ -106,6 +100,20 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end
defp maybe_get_container(%{"container_id" => container_id}, user)
when is_binary(container_id) do
container_id |> Containers.get_container!(user)
end
defp maybe_get_container(_params_not_found, _user), do: nil
defp maybe_get_ammo_type(%{"ammo_type_id" => ammo_type_id}, user)
when is_binary(ammo_type_id) do
ammo_type_id |> Ammo.get_ammo_type!(user)
end
defp maybe_get_ammo_type(_params_not_found, _user), do: nil
defp save_ammo_group( defp save_ammo_group(
%{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} =
socket, socket,
@ -146,16 +154,18 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
multiplier: multiplier multiplier: multiplier
) )
{:error, changeset} = save_multiplier_error(socket, changeset, error_msg)
changeset
|> Changeset.add_error(:multiplier, error_msg)
|> Changeset.apply_action(:insert)
socket |> assign(:changeset, changeset)
:error -> :error ->
error_msg = dgettext("errors", "Could not parse number of copies") error_msg = dgettext("errors", "Could not parse number of copies")
save_multiplier_error(socket, changeset, error_msg)
end
{:noreply, socket}
end
@spec save_multiplier_error(Socket.t(), Changeset.t(), String.t()) :: Socket.t()
defp save_multiplier_error(socket, changeset, error_msg) do
{:error, changeset} = {:error, changeset} =
changeset changeset
|> Changeset.add_error(:multiplier, error_msg) |> Changeset.add_error(:multiplier, error_msg)
@ -164,9 +174,6 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end
{:noreply, socket}
end
defp create_multiple( defp create_multiple(
%{assigns: %{current_user: current_user, return_to: return_to}} = socket, %{assigns: %{current_user: current_user, return_to: return_to}} = socket,
ammo_group_params, ammo_group_params,

View File

@ -12,11 +12,12 @@
phx-submit="save" 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" 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
<div class="invalid-feedback col-span-3 text-center"> :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<% end %>
<%= label(f, :ammo_type_id, gettext("Ammo type"), class: "title text-lg text-primary-600") %> <%= 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), <%= select(f, :ammo_type_id, ammo_type_options(@ammo_types),
@ -48,8 +49,10 @@
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes, <%= textarea(f, :notes,
id: "ammo-group-form-notes",
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
phx_hook: "MaintainAttrs" phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :notes, "col-span-3 text-center") %> <%= error_tag(f, :notes, "col-span-3 text-center") %>
@ -59,8 +62,8 @@
) %> ) %>
<%= error_tag(f, :container_id, "col-span-3 text-center") %> <%= error_tag(f, :container_id, "col-span-3 text-center") %>
<%= cond do %> <%= case @action do %>
<% @action in [:new, :clone] -> %> <% action when action in [:new, :clone] -> %>
<hr class="hr col-span-3" /> <hr class="hr col-span-3" />
<%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %> <%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %>
@ -77,7 +80,7 @@
) %> ) %>
<%= error_tag(f, :multiplier, "col-span-3 text-center") %> <%= error_tag(f, :multiplier, "col-span-3 text-center") %>
<% @action == :edit -> %> <% :edit -> %>
<%= submit(dgettext("actions", "Save"), <%= submit(dgettext("actions", "Save"),
phx_disable_with: dgettext("prompts", "Saving..."), phx_disable_with: dgettext("prompts", "Saving..."),
class: "mx-auto col-span-3 btn btn-primary" class: "mx-auto col-span-3 btn btn-primary"

View File

@ -8,11 +8,11 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
@impl true @impl true
def mount(%{"search" => search}, _session, socket) do def mount(%{"search" => search}, _session, socket) do
{:ok, socket |> assign(show_used: false, search: search) |> display_ammo_groups()} {:ok, socket |> assign(type: :all, show_used: false, search: search) |> display_ammo_groups()}
end end
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket |> assign(show_used: false, search: nil) |> display_ammo_groups()} {:ok, socket |> assign(type: :all, show_used: false, search: nil) |> display_ammo_groups()}
end end
@impl true @impl true
@ -91,7 +91,6 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
{:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()} {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()}
end end
@impl true
def handle_event( def handle_event(
"toggle_staged", "toggle_staged",
%{"ammo_group_id" => id}, %{"ammo_group_id" => id},
@ -105,12 +104,10 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
{:noreply, socket |> display_ammo_groups()} {:noreply, socket |> display_ammo_groups()}
end end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()} {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()}
end end
@impl true
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))} {:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))}
end end
@ -122,10 +119,36 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
{:noreply, socket} {:noreply, socket}
end end
def handle_event("change_type", %{"ammo_type" => %{"type" => "rifle"}}, socket) do
{:noreply, socket |> assign(:type, :rifle) |> display_ammo_groups()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "shotgun"}}, socket) do
{:noreply, socket |> assign(:type, :shotgun) |> display_ammo_groups()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "pistol"}}, socket) do
{:noreply, socket |> assign(:type, :pistol) |> display_ammo_groups()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => _all}}, socket) do
{:noreply, socket |> assign(:type, :all) |> display_ammo_groups()}
end
defp display_ammo_groups( defp display_ammo_groups(
%{assigns: %{search: search, current_user: current_user, show_used: show_used}} = socket %{
assigns: %{
type: type,
search: search,
current_user: current_user,
show_used: show_used
}
} = socket
) do ) do
ammo_groups = Ammo.list_ammo_groups(search, show_used, current_user) # get total number of ammo groups to determine whether to display onboarding
# prompts
ammo_groups_count = Ammo.get_ammo_groups_count!(current_user, true)
ammo_groups = Ammo.list_ammo_groups(search, type, current_user, show_used)
ammo_types_count = Ammo.get_ammo_types_count!(current_user) ammo_types_count = Ammo.get_ammo_types_count!(current_user)
containers_count = Containers.get_containers_count!(current_user) containers_count = Containers.get_containers_count!(current_user)
@ -133,7 +156,8 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
|> assign( |> assign(
ammo_groups: ammo_groups, ammo_groups: ammo_groups,
ammo_types_count: ammo_types_count, ammo_types_count: ammo_types_count,
containers_count: containers_count containers_count: containers_count,
ammo_groups_count: ammo_groups_count
) )
end end
end end

View File

@ -3,13 +3,6 @@
<%= gettext("Ammo") %> <%= gettext("Ammo") %>
</h1> </h1>
<%= if @ammo_groups |> Enum.empty?() and @search |> is_nil() do %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No Ammo") %>
<%= display_emoji("😔") %>
</h2>
<% end %>
<%= cond do %> <%= cond do %>
<% @containers_count == 0 -> %> <% @containers_count == 0 -> %>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@ -31,7 +24,12 @@
<%= dgettext("actions", "add an ammo type first") %> <%= dgettext("actions", "add an ammo type first") %>
</.link> </.link>
</div> </div>
<% @ammo_groups |> Enum.empty?() and @search |> is_nil() -> %> <% @ammo_groups_count == 0 -> %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No ammo") %>
<%= display_emoji("😔") %>
</h2>
<.link patch={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 your first box!") %> <%= dgettext("actions", "Add your first box!") %>
</.link> </.link>
@ -39,20 +37,44 @@
<.link patch={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") %> <%= dgettext("actions", "Add Ammo") %>
</.link> </.link>
<% 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"> <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl">
<.form <.form
:let={f} :let={f}
for={:search} for={%{}}
as={:ammo_type}
phx-change="change_type"
phx-submit="change_type"
class="flex items-center"
>
<%= label(f, :type, gettext("Type"), class: "title text-primary-600 text-lg text-center") %>
<%= select(
f,
:type,
[
{gettext("All"), :all},
{gettext("Rifle"), :rifle},
{gettext("Shotgun"), :shotgun},
{gettext("Pistol"), :pistol}
],
class: "mx-2 my-1 min-w-md input input-primary",
value: @type
) %>
</.form>
<.form
:let={f}
for={%{}}
as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow self-stretch flex flex-col items-stretch" class="grow flex items-center"
data-qa="ammo_group_search"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "input input-primary", class: "grow input input-primary",
value: @search, value: @search,
role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("Search ammo") placeholder: gettext("Search ammo")
) %> ) %>
@ -76,6 +98,7 @@
id="ammo-group-index-table" id="ammo-group-index-table"
ammo_groups={@ammo_groups} ammo_groups={@ammo_groups}
current_user={@current_user} current_user={@current_user}
show_used={@show_used}
> >
<:ammo_type :let={%{name: ammo_type_name} = ammo_type}> <:ammo_type :let={%{name: ammo_type_name} = ammo_type}>
<.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link">
@ -90,7 +113,9 @@
phx-click="toggle_staged" phx-click="toggle_staged"
phx-value-ammo_group_id={ammo_group.id} phx-value-ammo_group_id={ammo_group.id}
> >
<%= if ammo_group.staged, do: gettext("Unstage"), else: gettext("Stage") %> <%= if ammo_group.staged,
do: dgettext("actions", "Unstage"),
else: dgettext("actions", "Stage") %>
</button> </button>
<.link <.link
@ -101,7 +126,7 @@
</.link> </.link>
</div> </div>
</:range> </:range>
<:container :let={%{container: %{name: container_name} = container} = ammo_group}> <:container :let={{ammo_group, %{name: container_name} = container}}>
<div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center">
<.link <.link
navigate={Routes.container_show_path(Endpoint, :show, container)} navigate={Routes.container_show_path(Endpoint, :show, container)}
@ -114,16 +139,20 @@
patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)} patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)}
class="mx-2 my-1 text-sm btn btn-primary" class="mx-2 my-1 text-sm btn btn-primary"
> >
<%= gettext("Move ammo") %> <%= dgettext("actions", "Move ammo") %>
</.link> </.link>
</div> </div>
</:container> </:container>
<:actions :let={ammo_group}> <:actions :let={%{count: ammo_group_count} = ammo_group}>
<div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center">
<.link <.link
navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)} navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"view-#{ammo_group.id}"} aria-label={
dgettext("actions", "View ammo group of %{ammo_group_count} bullets",
ammo_group_count: ammo_group_count
)
}
> >
<i class="fa-fw fa-lg fas fa-eye"></i> <i class="fa-fw fa-lg fas fa-eye"></i>
</.link> </.link>
@ -131,7 +160,11 @@
<.link <.link
patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)} patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"edit-#{ammo_group.id}"} aria-label={
dgettext("actions", "Edit ammo group of %{ammo_group_count} bullets",
ammo_group_count: ammo_group_count
)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -139,7 +172,11 @@
<.link <.link
patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)} patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"clone-#{ammo_group.id}"} aria-label={
dgettext("actions", "Clone ammo group of %{ammo_group_count} bullets",
ammo_group_count: ammo_group_count
)
}
> >
<i class="fa-fw fa-lg fas fa-copy"></i> <i class="fa-fw fa-lg fas fa-copy"></i>
</.link> </.link>
@ -150,7 +187,11 @@
phx-click="delete" phx-click="delete"
phx-value-id={ammo_group.id} phx-value-id={ammo_group.id}
data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")}
data-qa={"delete-#{ammo_group.id}"} aria-label={
dgettext("actions", "Delete ammo group of %{ammo_group_count} bullets",
ammo_group_count: ammo_group_count
)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
@ -158,10 +199,11 @@
</:actions> </:actions>
</.live_component> </.live_component>
<% end %> <% end %>
<% end %>
</div> </div>
<%= cond do %> <%= case @live_action do %>
<% @live_action in [:new, :edit, :clone] -> %> <% create when create in [:new, :edit, :clone] -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.AmmoGroupLive.FormComponent} module={CanneryWeb.AmmoGroupLive.FormComponent}
@ -173,7 +215,7 @@
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% @live_action == :add_shot_group -> %> <% :add_shot_group -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.Components.AddShotGroupComponent} module={CanneryWeb.Components.AddShotGroupComponent}
@ -185,7 +227,7 @@
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% @live_action == :move -> %> <% :move -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.Components.MoveAmmoGroupComponent} module={CanneryWeb.Components.MoveAmmoGroupComponent}
@ -197,6 +239,5 @@
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% true -> %> <% _ -> %>
<%= nil %>
<% end %> <% end %>

View File

@ -4,8 +4,9 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
import CanneryWeb.Components.ContainerCard alias Cannery.{ActivityLog, ActivityLog.ShotGroup}
alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup, Repo} alias Cannery.{Ammo, Ammo.AmmoGroup}
alias Cannery.{ComparableDate, Containers}
alias CanneryWeb.Endpoint alias CanneryWeb.Endpoint
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@ -28,7 +29,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: live_action}} = socket) do def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: live_action}} = socket) do
socket = socket =
socket socket
@ -58,7 +58,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
{:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)}
end end
@impl true
def handle_event( def handle_event(
"toggle_staged", "toggle_staged",
_params, _params,
@ -70,7 +69,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
{:noreply, socket |> display_ammo_group(ammo_group)} {:noreply, socket |> display_ammo_group(ammo_group)}
end end
@impl true
def handle_event( def handle_event(
"delete_shot_group", "delete_shot_group",
%{"id" => id}, %{"id" => id},
@ -85,30 +83,45 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
end end
@spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t() @spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t()
defp display_ammo_group(socket, %AmmoGroup{} = ammo_group) do defp display_ammo_group(
ammo_group = ammo_group |> Repo.preload([:container, :ammo_type, :shot_groups], force: true) %{assigns: %{current_user: current_user}} = socket,
%AmmoGroup{container_id: container_id} = ammo_group
) do
columns = [ columns = [
%{label: gettext("Rounds shot"), key: :count}, %{label: gettext("Rounds shot"), key: :count},
%{label: gettext("Notes"), key: :notes}, %{label: gettext("Notes"), key: :notes},
%{label: gettext("Date"), key: :date}, %{label: gettext("Date"), key: :date, type: ComparableDate},
%{label: nil, key: :actions, sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
shot_groups = ActivityLog.list_shot_groups_for_ammo_group(ammo_group, current_user)
rows = rows =
ammo_group.shot_groups shot_groups
|> Enum.map(fn shot_group -> |> Enum.map(fn shot_group ->
ammo_group |> get_table_row_for_shot_group(shot_group, columns) ammo_group |> get_table_row_for_shot_group(shot_group, columns)
end) end)
socket |> assign(ammo_group: ammo_group, columns: columns, rows: rows) socket
|> assign(
ammo_group: ammo_group,
original_count: Ammo.get_original_count(ammo_group, current_user),
percentage_remaining: Ammo.get_percentage_remaining(ammo_group, current_user),
container: container_id && Containers.get_container!(container_id, current_user),
shot_groups: shot_groups,
columns: columns,
rows: rows
)
end end
defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id), defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id),
do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user)) do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user))
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
@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 defp get_table_row_for_shot_group(ammo_group, %{id: id, date: date} = shot_group, columns) do
assigns = %{ammo_group: ammo_group, shot_group: shot_group} assigns = %{ammo_group: ammo_group, shot_group: shot_group}
columns columns
@ -116,7 +129,12 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
value = value =
case key do case key do
:date -> :date ->
{date, date |> display_date()} assigns = %{id: id, date: date}
{date,
~H"""
<.date id={"#{@id}-date"} date={@date} />
"""}
:actions -> :actions ->
~H""" ~H"""
@ -124,7 +142,11 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
<.link <.link
patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)} patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"edit-#{@shot_group.id}"} aria-label={
dgettext("actions", "Edit shot group of %{shot_group_count} shots",
shot_group_count: @shot_group.count
)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -135,7 +157,11 @@ defmodule CanneryWeb.AmmoGroupLive.Show do
phx-click="delete_shot_group" phx-click="delete_shot_group"
phx-value-id={@shot_group.id} phx-value-id={@shot_group.id}
data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")} data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")}
data-qa={"delete-#{@shot_group.id}"} aria-label={
dgettext("actions", "Delete shot record of %{shot_group_count} shots",
shot_group_count: @shot_group.count
)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>

View File

@ -11,12 +11,12 @@
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Original count:") %> <%= gettext("Original count:") %>
<%= Ammo.get_original_count(@ammo_group) %> <%= @original_count %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Percentage left:") %> <%= gettext("Percentage left:") %>
<%= gettext("%{percentage}%", percentage: @ammo_group |> Ammo.get_percentage_remaining()) %> <%= gettext("%{percentage}%", percentage: @percentage_remaining) %>
</span> </span>
<%= if @ammo_group.notes do %> <%= if @ammo_group.notes do %>
@ -28,23 +28,19 @@
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %> <%= gettext("Purchased on:") %>
<%= @ammo_group.purchased_on |> display_date() %> <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} />
</span> </span>
<%= if @ammo_group.price_paid do %> <%= if @ammo_group.price_paid do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Original cost:") %> <%= gettext("Original cost:") %>
<%= gettext("$%{amount}", <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %>
amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2)
) %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Current value:") %> <%= gettext("Current value:") %>
<%= gettext("$%{amount}", <%= gettext("$%{amount}",
amount: amount: display_currency(@ammo_group.price_paid * @percentage_remaining / 100)
(@ammo_group.price_paid * Ammo.get_percentage_remaining(@ammo_group) / 100)
|> :erlang.float_to_binary(decimals: 2)
) %> ) %>
</span> </span>
<% end %> <% end %>
@ -55,7 +51,6 @@
<.link <.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type)} navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type)}
class="mx-4 my-2 btn btn-primary" class="mx-4 my-2 btn btn-primary"
data-qa="details"
> >
<%= dgettext("actions", "View in Catalog") %> <%= dgettext("actions", "View in Catalog") %>
</.link> </.link>
@ -63,7 +58,11 @@
<.link <.link
patch={Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group)} patch={Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group)}
class="mx-4 my-2 text-primary-600 link" class="mx-4 my-2 text-primary-600 link"
data-qa="edit" aria-label={
dgettext("actions", "Edit ammo group of %{ammo_group_count} bullets",
ammo_group_count: @ammo_group.count
)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -73,7 +72,11 @@
class="mx-4 my-2 text-primary-600 link" class="mx-4 my-2 text-primary-600 link"
phx-click="delete" phx-click="delete"
data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")}
data-qa="delete" aria-label={
dgettext("actions", "Delete ammo group of %{ammo_group_count} bullets",
ammo_group_count: @ammo_group.count
)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
@ -89,9 +92,8 @@
<.link <.link
patch={Routes.ammo_group_show_path(Endpoint, :move, @ammo_group)} patch={Routes.ammo_group_show_path(Endpoint, :move, @ammo_group)}
class="btn btn-primary" class="btn btn-primary"
data-qa="move"
> >
<%= dgettext("actions", "Move containers") %> <%= dgettext("actions", "Move ammo") %>
</.link> </.link>
<.link <.link
@ -106,18 +108,18 @@
<hr class="mb-4 w-full" /> <hr class="mb-4 w-full" />
<div> <div>
<%= if @ammo_group.container do %> <%= if @container do %>
<h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl"> <h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl">
<%= gettext("Stored in") %> <%= gettext("Stored in") %>
</h1> </h1>
<.container_card container={@ammo_group.container} /> <.container_card container={@container} current_user={@current_user} />
<% else %> <% else %>
<%= gettext("This ammo is not in a container") %> <%= gettext("This ammo is not in a container") %>
<% end %> <% end %>
</div> </div>
<%= unless @ammo_group.shot_groups |> Enum.empty?() do %> <%= unless @shot_groups |> Enum.empty?() do %>
<hr class="mb-4 w-full" /> <hr class="mb-4 w-full" />
<h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl"> <h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl">

View File

@ -35,15 +35,18 @@ defmodule CanneryWeb.AmmoTypeLive.FormComponent do
ammo_type_params ammo_type_params
) do ) do
changeset_action = changeset_action =
cond do case action do
action in [:new, :clone] -> :insert create when create in [:new, :clone] -> :insert
action == :edit -> :update :edit -> :update
end end
changeset = changeset =
cond do case action do
action in [:new, :clone] -> ammo_type |> AmmoType.create_changeset(user, ammo_type_params) create when create in [:new, :clone] ->
action == :edit -> ammo_type |> AmmoType.update_changeset(ammo_type_params) ammo_type |> AmmoType.create_changeset(user, ammo_type_params)
:edit ->
ammo_type |> AmmoType.update_changeset(ammo_type_params)
end end
changeset = changeset =

View File

@ -11,94 +11,103 @@
phx-submit="save" 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" 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
<div class="invalid-feedback col-span-3 text-center"> :if={@changeset.action && not @changeset.valid?()}
<%= changeset_errors(@changeset) %> class="invalid-feedback col-span-3 text-center"
>
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
</div> </div>
<% end %>
<%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %>
<%= select(
f,
:type,
[{gettext("Rifle"), :rifle}, {gettext("Shotgun"), :shotgun}, {gettext("Pistol"), :pistol}],
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :type, "col-span-3 text-center") %>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %> <%= text_input(f, :name,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :name, "col-span-3 text-center") %> <%= error_tag(f, :name, "col-span-3 text-center") %>
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :desc, <%= textarea(f, :desc,
id: "ammo-type-form-desc",
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
phx_hook: "MaintainAttrs" phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :desc, "col-span-3 text-center") %> <%= error_tag(f, :desc, "col-span-3 text-center") %>
<a <h2 class="text-center title text-lg text-primary-600 col-span-3">
href="https://en.wikipedia.org/wiki/Bullet#Abbreviations" <%= gettext("Dimensions") %>
class="col-span-3 text-center link title text-md text-primary-600" </h2>
>
<%= gettext("Example bullet type abbreviations") %>
</a>
<%= label(f, :bullet_type, gettext("Bullet type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :bullet_type,
class: "text-center col-span-2 input input-primary",
placeholder: gettext("FMJ")
) %>
<%= error_tag(f, :bullet_type, "col-span-3 text-center") %>
<%= label(f, :bullet_core, gettext("Bullet core"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :bullet_core,
class: "text-center col-span-2 input input-primary",
placeholder: gettext("Steel")
) %>
<%= error_tag(f, :bullet_core, "col-span-3 text-center") %>
<%= if Changeset.get_field(@changeset, :type) in [:rifle, :pistol] do %>
<%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %> <%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :cartridge, <%= text_input(f, :cartridge,
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
placeholder: "5.56x46mm NATO" maxlength: 255,
placeholder: gettext("5.56x46mm NATO")
) %> ) %>
<%= error_tag(f, :cartridge, "col-span-3 text-center") %> <%= error_tag(f, :cartridge, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :cartridge, value: nil) %>
<% end %>
<%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %> <%= label(
f,
:caliber,
if(Changeset.get_field(@changeset, :type) == :shotgun,
do: gettext("Gauge"),
else: gettext("Caliber")
),
class: "title text-lg text-primary-600"
) %>
<%= text_input(f, :caliber, <%= text_input(f, :caliber,
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
placeholder: ".223" maxlength: 255,
placeholder: gettext(".223")
) %> ) %>
<%= error_tag(f, :caliber, "col-span-3 text-center") %> <%= error_tag(f, :caliber, "col-span-3 text-center") %>
<%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %> <%= if Changeset.get_field(@changeset, :type) == :shotgun do %>
<%= text_input(f, :case_material, <%= label(f, :unfired_length, gettext("Unfired shell length"),
class: "text-center col-span-2 input input-primary",
placeholder: gettext("Brass")
) %>
<%= error_tag(f, :case_material, "col-span-3 text-center") %>
<%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :jacket_type,
class: "text-center col-span-2 input input-primary",
placeholder: gettext("Bimetal")
) %>
<%= error_tag(f, :case_material, "col-span-3 text-center") %>
<%= label(f, :muzzle_velocity, gettext("Muzzle velocity"),
class: "title text-lg text-primary-600" class: "title text-lg text-primary-600"
) %> ) %>
<%= number_input(f, :muzzle_velocity, <%= text_input(f, :unfired_length,
step: "1",
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
min: 1 maxlength: 255
) %> ) %>
<%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %> <%= error_tag(f, :unfired_length, "col-span-3 text-center") %>
<%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %> <%= label(f, :brass_height, gettext("Brass height"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :powder_type, class: "text-center col-span-2 input input-primary") %> <%= text_input(f, :brass_height,
<%= error_tag(f, :powder_type, "col-span-3 text-center") %>
<%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"),
class: "title text-lg text-primary-600"
) %>
<%= number_input(f, :powder_grains_per_charge,
step: "1",
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
min: 1 maxlength: 255
) %> ) %>
<%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %> <%= error_tag(f, :brass_height, "col-span-3 text-center") %>
<%= label(f, :chamber_size, gettext("Chamber size"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :chamber_size,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :chamber_size, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :unfired_length, value: nil) %>
<%= hidden_input(f, :brass_height, value: nil) %>
<%= hidden_input(f, :chamber_size, value: nil) %>
<% end %>
<h2 class="text-center title text-lg text-primary-600 col-span-3">
<%= gettext("Projectile") %>
</h2>
<%= label(f, :grains, gettext("Grains"), class: "title text-lg text-primary-600") %> <%= label(f, :grains, gettext("Grains"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :grains, <%= number_input(f, :grains,
@ -108,27 +117,202 @@
) %> ) %>
<%= error_tag(f, :grains, "col-span-3 text-center") %> <%= error_tag(f, :grains, "col-span-3 text-center") %>
<%= label f, :bullet_type, class: "flex title text-lg text-primary-600 space-x-2" do %>
<p><%= gettext("Bullet type") %></p>
<.link
href="https://shootersreference.com/reloadingdata/bullet_abbreviations/"
class="link"
target="_blank"
rel="noopener noreferrer"
>
<i class="fas fa-md fa-external-link-alt"></i>
</.link>
<% end %>
<%= text_input(f, :bullet_type,
class: "text-center col-span-2 input input-primary",
maxlength: 255,
placeholder: gettext("FMJ")
) %>
<%= error_tag(f, :bullet_type, "col-span-3 text-center") %>
<%= label(
f,
:bullet_core,
if(Changeset.get_field(@changeset, :type) == :shotgun,
do: gettext("Slug core"),
else: gettext("Bullet core")
),
class: "title text-lg text-primary-600"
) %>
<%= text_input(f, :bullet_core,
class: "text-center col-span-2 input input-primary",
maxlength: 255,
placeholder: gettext("Steel")
) %>
<%= error_tag(f, :bullet_core, "col-span-3 text-center") %>
<%= if Changeset.get_field(@changeset, :type) in [:rifle, :pistol] do %>
<%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :jacket_type,
class: "text-center col-span-2 input input-primary",
maxlength: 255,
placeholder: gettext("Bimetal")
) %>
<%= error_tag(f, :jacket_type, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :jacket_type, value: nil) %>
<% end %>
<%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :case_material,
class: "text-center col-span-2 input input-primary",
maxlength: 255,
placeholder: gettext("Brass")
) %>
<%= error_tag(f, :case_material, "col-span-3 text-center") %>
<%= if Changeset.get_field(@changeset, :type) == :shotgun do %>
<%= label(f, :wadding, gettext("Wadding"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :wadding,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :wadding, "col-span-3 text-center") %>
<%= label(f, :shot_type, gettext("Shot type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :shot_type,
class: "text-center col-span-2 input input-primary",
maxlength: 255,
placeholder: gettext("Target, bird, buck, etc")
) %>
<%= error_tag(f, :shot_type, "col-span-3 text-center") %>
<%= label(f, :shot_material, gettext("Shot material"),
class: "title text-lg text-primary-600"
) %>
<%= text_input(f, :shot_material,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :shot_material, "col-span-3 text-center") %>
<%= label(f, :shot_size, gettext("Shot size"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :shot_size,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :shot_size, "col-span-3 text-center") %>
<%= label(f, :load_grains, gettext("Load grains"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :load_grains,
step: "1",
class: "text-center col-span-2 input input-primary",
min: 1
) %>
<%= error_tag(f, :load_grains, "col-span-3 text-center") %>
<%= label(f, :shot_charge_weight, gettext("Shot charge weight"),
class: "title text-lg text-primary-600"
) %>
<%= text_input(f, :shot_charge_weight,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :shot_charge_weight, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :wadding, value: nil) %>
<%= hidden_input(f, :shot_type, value: nil) %>
<%= hidden_input(f, :shot_material, value: nil) %>
<%= hidden_input(f, :shot_size, value: nil) %>
<%= hidden_input(f, :load_grains, value: nil) %>
<%= hidden_input(f, :shot_charge_weight, value: nil) %>
<% end %>
<h2 class="text-center title text-lg text-primary-600 col-span-3">
<%= gettext("Powder") %>
</h2>
<%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :powder_type,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :powder_type, "col-span-3 text-center") %>
<%= if Changeset.get_field(@changeset, :type) in [:rifle, :pistol] do %>
<%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"),
class: "title text-lg text-primary-600"
) %>
<%= number_input(f, :powder_grains_per_charge,
step: "1",
class: "text-center col-span-2 input input-primary",
min: 1
) %>
<%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :powder_grains_per_charge, value: nil) %>
<% end %>
<%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %> <%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :pressure, <%= text_input(f, :pressure,
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
placeholder: "+P" maxlength: 255,
placeholder: gettext("+P")
) %> ) %>
<%= error_tag(f, :pressure, "col-span-3 text-center") %> <%= error_tag(f, :pressure, "col-span-3 text-center") %>
<%= if Changeset.get_field(@changeset, :type) == :shotgun do %>
<%= label(f, :dram_equivalent, gettext("Dram equivalent"),
class: "title text-lg text-primary-600"
) %>
<%= text_input(f, :dram_equivalent,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :dram_equivalent, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :dram_equivalent, value: nil) %>
<% end %>
<%= if Changeset.get_field(@changeset, :type) in [:rifle, :pistol] do %>
<%= label(f, :muzzle_velocity, gettext("Muzzle velocity"),
class: "title text-lg text-primary-600"
) %>
<%= number_input(f, :muzzle_velocity,
step: "1",
class: "text-center col-span-2 input input-primary",
min: 1
) %>
<%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %>
<% else %>
<%= hidden_input(f, :muzzle_velocity, value: nil) %>
<% end %>
<h2 class="text-center title text-lg text-primary-600 col-span-3">
<%= gettext("Primer") %>
</h2>
<%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %> <%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :primer_type, <%= text_input(f, :primer_type,
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
placeholder: "Boxer" maxlength: 255,
placeholder: gettext("Boxer")
) %> ) %>
<%= error_tag(f, :primer_type, "col-span-3 text-center") %> <%= error_tag(f, :primer_type, "col-span-3 text-center") %>
<%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %> <%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :firing_type, <%= text_input(f, :firing_type,
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
placeholder: "Centerfire" maxlength: 255,
placeholder: gettext("Centerfire")
) %> ) %>
<%= error_tag(f, :firing_type, "col-span-3 text-center") %> <%= error_tag(f, :firing_type, "col-span-3 text-center") %>
<h2 class="text-center title text-lg text-primary-600 col-span-3">
<%= gettext("Attributes") %>
</h2>
<%= label(f, :tracer, gettext("Tracer"), class: "title text-lg text-primary-600") %> <%= label(f, :tracer, gettext("Tracer"), class: "title text-lg text-primary-600") %>
<%= checkbox(f, :tracer, class: "text-center col-span-2 checkbox") %> <%= checkbox(f, :tracer, class: "text-center col-span-2 checkbox") %>
<%= error_tag(f, :tracer, "col-span-3 text-center") %> <%= error_tag(f, :tracer, "col-span-3 text-center") %>
@ -145,12 +329,22 @@
<%= checkbox(f, :corrosive, class: "text-center col-span-2 checkbox") %> <%= checkbox(f, :corrosive, class: "text-center col-span-2 checkbox") %>
<%= error_tag(f, :corrosive, "col-span-3 text-center") %> <%= error_tag(f, :corrosive, "col-span-3 text-center") %>
<h2 class="text-center title text-lg text-primary-600 col-span-3">
<%= gettext("Manufacturer") %>
</h2>
<%= label(f, :manufacturer, gettext("Manufacturer"), class: "title text-lg text-primary-600") %> <%= label(f, :manufacturer, gettext("Manufacturer"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :manufacturer, class: "text-center col-span-2 input input-primary") %> <%= text_input(f, :manufacturer,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :manufacturer, "col-span-3 text-center") %> <%= error_tag(f, :manufacturer, "col-span-3 text-center") %>
<%= label(f, :upc, gettext("UPC"), class: "title text-lg text-primary-600") %> <%= label(f, :upc, gettext("UPC"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :upc, class: "text-center col-span-2 input input-primary") %> <%= text_input(f, :upc,
class: "text-center col-span-2 input input-primary",
maxlength: 255
) %>
<%= error_tag(f, :upc, "col-span-3 text-center") %> <%= error_tag(f, :upc, "col-span-3 text-center") %>
<%= submit(dgettext("actions", "Save"), <%= submit(dgettext("actions", "Save"),

View File

@ -8,11 +8,11 @@ defmodule CanneryWeb.AmmoTypeLive.Index do
@impl true @impl true
def mount(%{"search" => search}, _session, socket) do def mount(%{"search" => search}, _session, socket) do
{:ok, socket |> assign(show_used: false, search: search) |> list_ammo_types()} {:ok, socket |> assign(type: :all, show_used: false, search: search) |> list_ammo_types()}
end end
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket |> assign(show_used: false, search: nil) |> list_ammo_types()} {:ok, socket |> assign(type: :all, show_used: false, search: nil) |> list_ammo_types()}
end end
@impl true @impl true
@ -69,28 +69,46 @@ defmodule CanneryWeb.AmmoTypeLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
%{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user) %{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user)
prompt = dgettext("prompts", "%{name} deleted succesfully", name: name) prompt = dgettext("prompts", "%{name} deleted succesfully", name: name)
{:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()} {:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()}
end end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()} {:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()}
end end
@impl true
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))} {:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))}
end end
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
{:noreply, search_path = Routes.ammo_type_index_path(Endpoint, :search, search_term)
socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :search, search_term))} {:noreply, socket |> push_patch(to: search_path)}
end end
defp list_ammo_types(%{assigns: %{search: search, current_user: current_user}} = socket) do def handle_event("change_type", %{"ammo_type" => %{"type" => "rifle"}}, socket) do
socket |> assign(ammo_types: Ammo.list_ammo_types(search, current_user)) {:noreply, socket |> assign(:type, :rifle) |> list_ammo_types()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "shotgun"}}, socket) do
{:noreply, socket |> assign(:type, :shotgun) |> list_ammo_types()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "pistol"}}, socket) do
{:noreply, socket |> assign(:type, :pistol) |> list_ammo_types()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => _all}}, socket) do
{:noreply, socket |> assign(:type, :all) |> list_ammo_types()}
end
defp list_ammo_types(
%{assigns: %{type: type, search: search, current_user: current_user}} = socket
) do
socket
|> assign(
ammo_types: Ammo.list_ammo_types(search, current_user, type),
ammo_types_count: Ammo.get_ammo_types_count!(current_user)
)
end end
end end

View File

@ -3,7 +3,7 @@
<%= gettext("Catalog") %> <%= gettext("Catalog") %>
</h1> </h1>
<%= if @ammo_types |> Enum.empty?() and @search |> is_nil() do %> <%= if @ammo_types_count == 0 do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= gettext("No Ammo types") %> <%= gettext("No Ammo types") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
@ -17,18 +17,43 @@
<%= dgettext("actions", "New Ammo type") %> <%= dgettext("actions", "New Ammo type") %>
</.link> </.link>
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl">
<.form <.form
:let={f} :let={f}
for={:search} for={%{}}
as={:ammo_type}
phx-change="change_type"
phx-submit="change_type"
class="flex items-center"
>
<%= label(f, :type, gettext("Type"), class: "title text-primary-600 text-lg text-center") %>
<%= select(
f,
:type,
[
{gettext("All"), :all},
{gettext("Rifle"), :rifle},
{gettext("Shotgun"), :shotgun},
{gettext("Pistol"), :pistol}
],
class: "mx-2 my-1 min-w-md input input-primary",
value: @type
) %>
</.form>
<.form
:let={f}
for={%{}}
as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow self-stretch flex flex-col items-stretch" class="grow flex items-center"
data-qa="ammo_type_search"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "input input-primary", class: "grow input input-primary",
value: @search, value: @search,
role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("Search catalog") placeholder: gettext("Search catalog")
) %> ) %>
@ -54,13 +79,16 @@
ammo_types={@ammo_types} ammo_types={@ammo_types}
current_user={@current_user} current_user={@current_user}
show_used={@show_used} show_used={@show_used}
type={@type}
> >
<:actions :let={ammo_type}> <:actions :let={ammo_type}>
<div class="px-4 py-2 space-x-4 flex justify-center items-center"> <div class="px-4 py-2 space-x-4 flex justify-center items-center">
<.link <.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"view-#{ammo_type.id}"} aria-label={
dgettext("actions", "View %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-eye"></i> <i class="fa-fw fa-lg fas fa-eye"></i>
</.link> </.link>
@ -68,7 +96,9 @@
<.link <.link
patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)} patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"edit-#{ammo_type.id}"} aria-label={
dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -76,7 +106,9 @@
<.link <.link
patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)} patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"clone-#{ammo_type.id}"} aria-label={
dgettext("actions", "Clone %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-copy"></i> <i class="fa-fw fa-lg fas fa-copy"></i>
</.link> </.link>
@ -93,7 +125,9 @@
name: ammo_type.name name: ammo_type.name
) )
} }
data-qa={"delete-#{ammo_type.id}"} aria-label={
dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-lg fas fa-trash"></i> <i class="fa-lg fas fa-trash"></i>
</.link> </.link>
@ -104,8 +138,10 @@
<% end %> <% end %>
</div> </div>
<%= if @live_action in [:new, :edit, :clone] do %> <.modal
<.modal return_to={Routes.ammo_type_index_path(Endpoint, :index)}> :if={@live_action in [:new, :edit, :clone]}
return_to={Routes.ammo_type_index_path(Endpoint, :index)}
>
<.live_component <.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent} module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id || :new} id={@ammo_type.id || :new}
@ -116,5 +152,4 @@
current_user={@current_user} current_user={@current_user}
} }
/> />
</.modal> </.modal>
<% end %>

View File

@ -4,44 +4,16 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
import CanneryWeb.Components.AmmoGroupCard alias Cannery.{ActivityLog, Ammo, Ammo.AmmoType, Containers}
alias Cannery.{Ammo, Ammo.AmmoType}
alias CanneryWeb.Endpoint alias CanneryWeb.Endpoint
@fields_list [ @impl true
%{label: gettext("Bullet type:"), key: :bullet_type, type: :string}, def mount(_params, _session, socket),
%{label: gettext("Bullet core:"), key: :bullet_core, type: :string}, do: {:ok, socket |> assign(show_used: false, view_table: true)}
%{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 @impl true
def mount(_params, _session, %{assigns: %{live_action: live_action}} = socket), def handle_params(%{"id" => id}, _params, socket) do
do: {:ok, socket |> assign(show_used: false, view_table: live_action == :table)} {:noreply, socket |> display_ammo_type(id)}
@impl true
def handle_params(%{"id" => id}, _params, %{assigns: %{live_action: live_action}} = socket) do
socket =
socket
|> assign(view_table: live_action == :table)
|> display_ammo_type(id)
{:noreply, socket}
end end
@impl true @impl true
@ -58,32 +30,21 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
{:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)}
end end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()} {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()}
end end
@impl true def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
def handle_event( {:noreply, socket |> assign(:view_table, !view_table)}
"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 end
defp display_ammo_type( defp display_ammo_type(
%{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} = %{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} =
socket, socket,
%AmmoType{} = ammo_type %AmmoType{name: ammo_type_name} = ammo_type
) do ) do
fields_to_display = custom_fields? =
@fields_list fields_to_display(ammo_type)
|> Enum.any?(fn %{key: field, type: type} -> |> Enum.any?(fn %{key: field, type: type} ->
default_value = default_value =
case type do case type do
@ -94,14 +55,56 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
ammo_type |> Map.get(field) != default_value ammo_type |> Map.get(field) != default_value
end) end)
ammo_groups = ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used)
[
original_counts,
used_packs_count,
historical_packs_count,
used_rounds,
historical_round_count
] =
if show_used do
[
ammo_groups |> Ammo.get_original_counts(current_user),
ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user),
ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true),
ammo_type |> ActivityLog.get_used_count_for_ammo_type(current_user),
ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user)
]
else
[nil, nil, nil, nil, nil]
end
page_title =
case live_action do
:show -> ammo_type_name
:edit -> gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name)
end
containers =
ammo_groups
|> Enum.map(fn %{container_id: container_id} -> container_id end)
|> Containers.get_containers(current_user)
socket socket
|> assign( |> assign(
page_title: page_title(live_action, ammo_type), page_title: page_title,
ammo_type: ammo_type, ammo_type: ammo_type,
ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used), ammo_groups: ammo_groups,
avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user), containers: containers,
fields_list: @fields_list, cprs: ammo_groups |> Ammo.get_cprs(current_user),
fields_to_display: fields_to_display last_used_dates: ammo_groups |> ActivityLog.get_last_used_dates(current_user),
avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type(current_user),
rounds: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user),
original_counts: original_counts,
used_rounds: used_rounds,
historical_round_count: historical_round_count,
packs_count: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user),
used_packs_count: used_packs_count,
historical_packs_count: historical_packs_count,
fields_to_display: fields_to_display(ammo_type),
custom_fields?: custom_fields?
) )
end end
@ -113,9 +116,48 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
socket |> display_ammo_type(ammo_type) socket |> display_ammo_type(ammo_type)
end end
defp page_title(action, %{name: ammo_type_name}) when action in [:show, :table], defp fields_to_display(%AmmoType{type: type}) do
do: ammo_type_name [
%{label: gettext("Cartridge:"), key: :cartridge, type: :string},
%{
label: if(type == :shotgun, do: gettext("Gauge:"), else: gettext("Caliber:")),
key: :caliber,
type: :string
},
%{label: gettext("Unfired length:"), key: :unfired_length, type: :string},
%{label: gettext("Brass height:"), key: :brass_height, type: :string},
%{label: gettext("Chamber size:"), key: :chamber_size, type: :string},
%{label: gettext("Grains:"), key: :grains, type: :string},
%{label: gettext("Bullet type:"), key: :bullet_type, type: :string},
%{label: gettext("Bullet core:"), key: :bullet_core, type: :string},
%{label: gettext("Jacket type:"), key: :jacket_type, type: :string},
%{label: gettext("Case material:"), key: :case_material, type: :string},
%{label: gettext("Wadding:"), key: :wadding, type: :string},
%{label: gettext("Shot type:"), key: :shot_type, type: :string},
%{label: gettext("Shot material:"), key: :shot_material, type: :string},
%{label: gettext("Shot size:"), key: :shot_size, type: :string},
%{label: gettext("Load grains:"), key: :load_grains, type: :string},
%{label: gettext("Shot charge weight:"), key: :shot_charge_weight, type: :string},
%{label: gettext("Powder type:"), key: :powder_type, type: :string},
%{
label: gettext("Powder grains per charge:"),
key: :powder_grains_per_charge,
type: :string
},
%{label: gettext("Pressure:"), key: :pressure, type: :string},
%{label: gettext("Dram equivalent:"), key: :dram_equivalent, type: :string},
%{label: gettext("Muzzle velocity:"), key: :muzzle_velocity, type: :string},
%{label: gettext("Primer type:"), key: :primer_type, type: :string},
%{label: gettext("Firing type:"), key: :firing_type, type: :string},
%{label: gettext("Tracer:"), key: :tracer, type: :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}
]
end
defp page_title(:edit, %{name: ammo_type_name}), @spec display_currency(float()) :: String.t()
do: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end end

View File

@ -3,19 +3,20 @@
<%= @ammo_type.name %> <%= @ammo_type.name %>
</h1> </h1>
<%= if @ammo_type.desc do %> <span
<span class="max-w-2xl w-full px-8 py-4 rounded-lg :if={@ammo_type.desc}
class="max-w-2xl w-full px-8 py-4 rounded-lg
text-center title text-lg text-center title text-lg
border border-primary-600"> border border-primary-600"
>
<%= @ammo_type.desc %> <%= @ammo_type.desc %>
</span> </span>
<% end %>
<div class="flex space-x-4 justify-center items-center text-primary-600"> <div class="flex space-x-4 justify-center items-center text-primary-600">
<.link <.link
patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)} patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa="edit" aria-label={dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: @ammo_type.name)}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -31,7 +32,9 @@
name: @ammo_type.name name: @ammo_type.name
) )
} }
data-qa="delete" aria-label={
dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: @ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
@ -39,9 +42,26 @@
<hr class="hr" /> <hr class="hr" />
<%= if @fields_to_display do %> <%= if @ammo_type.type || @custom_fields? do %>
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> <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 %> <h3 class="title text-lg">
<%= gettext("Type") %>
</h3>
<span class="text-primary-600">
<%= case @ammo_type.type do %>
<% :shotgun -> %>
<%= gettext("Shotgun") %>
<% :rifle -> %>
<%= gettext("Rifle") %>
<% :pistol -> %>
<%= gettext("Pistol") %>
<% _ -> %>
<%= gettext("None specified") %>
<% end %>
</span>
<%= for %{label: label, key: key, type: type} <- @fields_to_display do %>
<%= if @ammo_type |> Map.get(key) do %> <%= if @ammo_type |> Map.get(key) do %>
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= label %> <%= label %>
@ -68,15 +88,16 @@
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_round_count_for_ammo_type(@current_user) %> <%= @rounds %>
</span> </span>
<%= if @show_used do %>
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= gettext("Used rounds:") %> <%= gettext("Used rounds:") %>
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_used_count_for_ammo_type(@current_user) %> <%= @used_rounds %>
</span> </span>
<h3 class="title text-lg"> <h3 class="title text-lg">
@ -84,27 +105,25 @@
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_historical_count_for_ammo_type(@current_user) %> <%= @historical_round_count %>
</span> </span>
</div> <% end %>
<hr class="hr" />
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center">
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= gettext("Packs:") %> <%= gettext("Packs:") %>
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user) %> <%= @packs_count %>
</span> </span>
<%= if @show_used do %>
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= gettext("Used packs:") %> <%= gettext("Used packs:") %>
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_used_ammo_groups_count_for_type(@current_user) %> <%= @used_packs_count %>
</span> </span>
<h3 class="title text-lg"> <h3 class="title text-lg">
@ -112,19 +131,16 @@
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user, true) %> <%= @historical_packs_count %>
</span> </span>
</div> <% end %>
<hr class="hr" />
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center">
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= gettext("Added on:") %> <%= gettext("Added on:") %>
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= @ammo_type.inserted_at |> display_datetime() %> <.datetime id={"#{@ammo_type.id}-inserted-at"} datetime={@ammo_type.inserted_at} />
</span> </span>
<%= if @avg_cost_per_round do %> <%= if @avg_cost_per_round do %>
@ -133,9 +149,7 @@
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<%= gettext("$%{amount}", <%= gettext("$%{amount}", amount: display_currency(@avg_cost_per_round)) %>
amount: @avg_cost_per_round |> :erlang.float_to_binary(decimals: 2)
) %>
</span> </span>
<% else %> <% else %>
<h3 class="mx-8 my-4 title text-lg text-primary-600 col-span-2"> <h3 class="mx-8 my-4 title text-lg text-primary-600 col-span-2">
@ -173,8 +187,9 @@
id="ammo-type-show-table" id="ammo-type-show-table"
ammo_groups={@ammo_groups} ammo_groups={@ammo_groups}
current_user={@current_user} current_user={@current_user}
show_used={@show_used}
> >
<:container :let={%{container: %{name: container_name} = container}}> <:container :let={{_ammo_group, %{name: container_name} = container}}>
<.link <.link
navigate={Routes.container_show_path(Endpoint, :show, container)} navigate={Routes.container_show_path(Endpoint, :show, container)}
class="mx-2 my-1 link" class="mx-2 my-1 link"
@ -182,20 +197,43 @@
<%= container_name %> <%= container_name %>
</.link> </.link>
</:container> </:container>
<:actions :let={%{count: ammo_group_count} = 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"
aria-label={
dgettext("actions", "View ammo group of %{ammo_group_count} bullets",
ammo_group_count: ammo_group_count
)
}
>
<i class="fa-fw fa-lg fas fa-eye"></i>
</.link>
</div>
</:actions>
</.live_component> </.live_component>
<% else %> <% else %>
<div class="flex flex-wrap justify-center items-stretch"> <div class="flex flex-wrap justify-center items-stretch">
<%= for ammo_group <- @ammo_groups do %> <.ammo_group_card
<.ammo_group_card ammo_group={ammo_group} show_container={true} /> :for={%{id: ammo_group_id, container_id: container_id} = ammo_group <- @ammo_groups}
<% end %> ammo_group={ammo_group}
original_count={@original_counts && Map.fetch!(@original_counts, ammo_group_id)}
cpr={Map.get(@cprs, ammo_group_id)}
last_used_date={Map.get(@last_used_dates, ammo_group_id)}
current_user={@current_user}
container={Map.fetch!(@containers, container_id)}
/>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</div> </div>
<%= if @live_action in [:edit] do %> <.modal
<.modal return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}> :if={@live_action == :edit}
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
>
<.live_component <.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent} module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id} id={@ammo_type.id}
@ -205,5 +243,4 @@
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% end %>

View File

@ -4,25 +4,41 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Containers, Containers.Container, Repo, Tags, Tags.Tag} alias Cannery.{Accounts.User, Containers}
alias Cannery.Containers.{Container, Tag}
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
@spec update( @spec update(
%{:container => Container.t(), :current_user => User.t(), optional(any) => any}, %{
:container => Container.t(),
:current_path => String.t(),
:current_user => User.t(),
optional(any) => any
},
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{container: container, current_user: current_user} = assigns, socket) do def update(
tags = Tags.list_tags(current_user) %{container: _container, current_path: _current_path, current_user: current_user} =
container = container |> Repo.preload(:tags) assigns,
{:ok, socket |> assign(assigns) |> assign(tags: tags, container: container)} socket
) do
tags = Containers.list_tags(current_user)
{:ok, socket |> assign(assigns) |> assign(:tags, tags)}
end end
@impl true @impl true
def handle_event( def handle_event(
"save", "save",
%{"tag" => %{"tag_id" => tag_id}}, %{"tag" => %{"tag_id" => tag_id}},
%{assigns: %{tags: tags, container: container, current_user: current_user}} = socket %{
assigns: %{
tags: tags,
container: container,
current_user: current_user,
current_path: current_path
}
} = socket
) do ) do
socket = socket =
case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do
@ -32,19 +48,24 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do
%{name: tag_name} = tag -> %{name: tag_name} = tag ->
_container_tag = Containers.add_tag!(container, tag, current_user) _container_tag = Containers.add_tag!(container, tag, current_user)
container = container |> Repo.preload(:tags, force: true)
prompt = dgettext("prompts", "%{name} added successfully", name: tag_name) prompt = dgettext("prompts", "%{name} added successfully", name: tag_name)
socket |> put_flash(:info, prompt) |> assign(container: container) socket |> put_flash(:info, prompt) |> push_patch(to: current_path)
end end
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event( def handle_event(
"delete", "delete",
%{"tag-id" => tag_id}, %{"tag-id" => tag_id},
%{assigns: %{tags: tags, container: container, current_user: current_user}} = socket %{
assigns: %{
tags: tags,
container: container,
current_user: current_user,
current_path: current_path
}
} = socket
) do ) do
socket = socket =
case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do
@ -54,9 +75,8 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do
%{name: tag_name} = tag -> %{name: tag_name} = tag ->
_container_tag = Containers.remove_tag!(container, tag, current_user) _container_tag = Containers.remove_tag!(container, tag, current_user)
container = container |> Repo.preload(:tags, force: true)
prompt = dgettext("prompts", "%{name} removed successfully", name: tag_name) prompt = dgettext("prompts", "%{name} removed successfully", name: tag_name)
socket |> put_flash(:info, prompt) |> assign(container: container) socket |> put_flash(:info, prompt) |> push_patch(to: current_path)
end end
{:noreply, socket} {:noreply, socket}

View File

@ -4,8 +4,8 @@
</h2> </h2>
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<%= for tag <- @container.tags do %>
<.link <.link
:for={tag <- @container.tags}
href="#" href="#"
class="mx-2 my-1 px-4 py-2 rounded-lg title text-xl" class="mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{tag.text_color}; background-color: #{tag.bg_color}"} style={"color: #{tag.text_color}; background-color: #{tag.bg_color}"}
@ -24,14 +24,11 @@
<%= tag.name %> <%= tag.name %>
<i class="fa-fw fa-sm fas fa-trash"></i> <i class="fa-fw fa-sm fas fa-trash"></i>
</.link> </.link>
<% end %>
<%= if @container.tags |> Enum.empty?() do %> <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600">
<h2 class="title text-xl text-primary-600">
<%= gettext("No tags") %> <%= gettext("No tags") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<% end %>
</div> </div>
<%= unless tag_options(@tags, @container) |> Enum.empty?() do %> <%= unless tag_options(@tags, @container) |> Enum.empty?() do %>
@ -39,7 +36,8 @@
<.form <.form
:let={f} :let={f}
for={:tag} for={%{}}
as={:tag}
id="add-tag-to-container-form" id="add-tag-to-container-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
phx-target={@myself} phx-target={@myself}

View File

@ -35,17 +35,17 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
container_params container_params
) do ) do
changeset_action = changeset_action =
cond do case action do
action in [:new, :clone] -> :insert create when create in [:new, :clone] -> :insert
action == :edit -> :update :edit -> :update
end end
changeset = changeset =
cond do case action do
action in [:new, :clone] -> create when create in [:new, :clone] ->
container |> Container.create_changeset(user, container_params) container |> Container.create_changeset(user, container_params)
action == :edit -> :edit ->
container |> Container.update_changeset(container_params) container |> Container.update_changeset(container_params)
end end

View File

@ -11,39 +11,46 @@
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
> >
<%= if @changeset.action && not @changeset.valid? do %> <div
<div class="invalid-feedback col-span-3 text-center"> :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<% end %>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :name, <%= text_input(f, :name,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("My cool ammo can") placeholder: gettext("My cool ammo can"),
maxlength: 255
) %> ) %>
<%= error_tag(f, :name, "col-span-3 text-center") %> <%= error_tag(f, :name, "col-span-3 text-center") %>
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :desc, <%= textarea(f, :desc,
id: "container-form-desc",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("Metal ammo can with the anime girl sticker"),
phx_hook: "MaintainAttrs", phx_hook: "MaintainAttrs",
placeholder: gettext("Metal ammo can with the anime girl sticker") phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :desc, "col-span-3 text-center") %> <%= error_tag(f, :desc, "col-span-3 text-center") %>
<%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :type, <%= text_input(f, :type,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("Magazine, Clip, Ammo Box, etc") placeholder: gettext("Magazine, Clip, Ammo Box, etc"),
maxlength: 255
) %> ) %>
<%= error_tag(f, :type, "col-span-3 text-center") %> <%= error_tag(f, :type, "col-span-3 text-center") %>
<%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> <%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :location, <%= textarea(f, :location,
id: "container-form-location",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("On the bookshelf"),
phx_hook: "MaintainAttrs", phx_hook: "MaintainAttrs",
placeholder: gettext("On the bookshelf") phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :location, "col-span-3 text-center") %> <%= error_tag(f, :location, "col-span-3 text-center") %>

View File

@ -4,8 +4,7 @@ defmodule CanneryWeb.ContainerLive.Index do
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
import CanneryWeb.Components.ContainerCard alias Cannery.{Containers, Containers.Container}
alias Cannery.{Containers, Containers.Container, Repo}
alias Ecto.Changeset alias Ecto.Changeset
@impl true @impl true
@ -23,10 +22,7 @@ defmodule CanneryWeb.ContainerLive.Index do
end end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
%{name: container_name} = %{name: container_name} = container = Containers.get_container!(id, current_user)
container =
Containers.get_container!(id, current_user)
|> Repo.preload([:tags, :ammo_groups])
socket socket
|> assign(page_title: gettext("Edit %{name}", name: container_name), container: container) |> assign(page_title: gettext("Edit %{name}", name: container_name), container: container)
@ -62,9 +58,7 @@ defmodule CanneryWeb.ContainerLive.Index do
end end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do
%{name: container_name} = %{name: container_name} = container = Containers.get_container!(id, current_user)
container =
Containers.get_container!(id, current_user) |> Repo.preload([:tags, :ammo_groups])
page_title = gettext("Edit %{name} tags", name: container_name) page_title = gettext("Edit %{name} tags", name: container_name)
socket |> assign(page_title: page_title, container: container) socket |> assign(page_title: page_title, container: container)
@ -106,12 +100,10 @@ defmodule CanneryWeb.ContainerLive.Index do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table) |> display_containers()} {:noreply, socket |> assign(:view_table, !view_table) |> display_containers()}
end end
@impl true
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))} {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))}
end end
@ -122,10 +114,6 @@ defmodule CanneryWeb.ContainerLive.Index do
end end
defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do
containers = socket |> assign(:containers, Containers.list_containers(search, current_user))
Containers.list_containers(search, current_user)
|> Repo.preload([:tags, :ammo_groups])
socket |> assign(:containers, containers)
end end
end end

View File

@ -17,18 +17,19 @@
<%= dgettext("actions", "New Container") %> <%= dgettext("actions", "New Container") %>
</.link> </.link>
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl">
<.form <.form
:let={f} :let={f}
for={:search} for={%{}}
as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow self-stretch flex flex-col items-stretch" class="grow flex items-center"
data-qa="container_search"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "input input-primary", class: "grow input input-primary",
value: @search, value: @search,
role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("Search containers") placeholder: gettext("Search containers")
) %> ) %>
@ -40,7 +41,6 @@
</span> </span>
</.toggle_button> </.toggle_button>
</div> </div>
<% end %>
<%= if @containers |> Enum.empty?() do %> <%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
@ -61,6 +61,9 @@
<.link <.link
patch={Routes.container_index_path(Endpoint, :edit_tags, container)} patch={Routes.container_index_path(Endpoint, :edit_tags, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={
dgettext("actions", "Tag %{container_name}", container_name: container.name)
}
> >
<i class="fa-fw fa-lg fas fa-tags"></i> <i class="fa-fw fa-lg fas fa-tags"></i>
</.link> </.link>
@ -70,51 +73,9 @@
<.link <.link
patch={Routes.container_index_path(Endpoint, :edit, container)} patch={Routes.container_index_path(Endpoint, :edit, container)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"edit-#{container.id}"} aria-label={
> dgettext("actions", "Edit %{container_name}", container_name: container.name)
<i class="fa-fw fa-lg fas fa-edit"></i>
</.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>
</:actions>
</.live_component>
<% else %>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<%= for container <- @containers do %>
<.container_card 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> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -122,7 +83,9 @@
<.link <.link
patch={Routes.container_index_path(Endpoint, :clone, container)} patch={Routes.container_index_path(Endpoint, :clone, container)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"clone-#{container.id}"} aria-label={
dgettext("actions", "Clone %{container_name}", container_name: container.name)
}
> >
<i class="fa-fw fa-lg fas fa-copy"></i> <i class="fa-fw fa-lg fas fa-copy"></i>
</.link> </.link>
@ -137,18 +100,79 @@
name: container.name name: container.name
) )
} }
data-qa={"delete-#{container.id}"} aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</:actions>
</.live_component>
<% else %>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<.container_card
:for={container <- @containers}
container={container}
current_user={@current_user}
>
<:tag_actions>
<div class="mx-4 my-2">
<.link
patch={Routes.container_index_path(Endpoint, :edit_tags, container)}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Tag %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-tags"></i>
</.link>
</div>
</:tag_actions>
<.link
patch={Routes.container_index_path(Endpoint, :edit, container)}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Edit %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-edit"></i>
</.link>
<.link
patch={Routes.container_index_path(Endpoint, :clone, container)}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Clone %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-copy"></i>
</.link>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete"
phx-value-id={container.id}
data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?",
name: container.name
)
}
aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
</.container_card> </.container_card>
<% end %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% end %>
</div> </div>
<%= if @live_action in [:new, :edit, :clone] do %> <%= case @live_action do %>
<% modifying when modifying in [:new, :edit, :clone] -> %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}> <.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.FormComponent} module={CanneryWeb.ContainerLive.FormComponent}
@ -160,9 +184,7 @@
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% end %> <% :edit_tags -> %>
<%= if @live_action == :edit_tags do %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}> <.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent} module={CanneryWeb.ContainerLive.EditTagsComponent}
@ -170,7 +192,9 @@
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
container={@container} container={@container}
current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% _ -> %>
<% end %> <% end %>

View File

@ -4,15 +4,14 @@ defmodule CanneryWeb.ContainerLive.Show do
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
import CanneryWeb.Components.{AmmoGroupCard, TagCard} alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container}
alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container, Repo, Tags}
alias CanneryWeb.Endpoint alias CanneryWeb.Endpoint
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
def mount(_params, _session, socket), def mount(_params, _session, socket),
do: {:ok, socket |> assign(show_used: false, view_table: true)} do: {:ok, socket |> assign(type: :all, view_table: true)}
@impl true @impl true
def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do
@ -31,7 +30,7 @@ defmodule CanneryWeb.ContainerLive.Show do
%{assigns: %{container: container, current_user: current_user}} = socket %{assigns: %{container: container, current_user: current_user}} = socket
) do ) do
socket = socket =
case Tags.get_tag(tag_id, current_user) do case Containers.get_tag(tag_id, current_user) do
{:ok, tag} -> {:ok, tag} ->
_count = Containers.remove_tag!(container, tag, current_user) _count = Containers.remove_tag!(container, tag, current_user)
@ -43,14 +42,13 @@ defmodule CanneryWeb.ContainerLive.Show do
socket |> put_flash(:info, prompt) |> render_container() socket |> put_flash(:info, prompt) |> render_container()
{:error, error_string} -> {:error, :not_found} ->
socket |> put_flash(:error, error_string) socket |> put_flash(:error, dgettext("errors", "Tag not found"))
end end
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event( def handle_event(
"delete_container", "delete_container",
_params, _params,
@ -84,37 +82,56 @@ defmodule CanneryWeb.ContainerLive.Show do
{:noreply, socket} {:noreply, socket}
end 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 def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table) |> render_container()} {:noreply, socket |> assign(:view_table, !view_table) |> render_container()}
end end
def handle_event("change_type", %{"ammo_type" => %{"type" => "rifle"}}, socket) do
{:noreply, socket |> assign(:type, :rifle) |> render_container()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "shotgun"}}, socket) do
{:noreply, socket |> assign(:type, :shotgun) |> render_container()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => "pistol"}}, socket) do
{:noreply, socket |> assign(:type, :pistol) |> render_container()}
end
def handle_event("change_type", %{"ammo_type" => %{"type" => _all}}, socket) do
{:noreply, socket |> assign(:type, :all) |> render_container()}
end
@spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t()
defp render_container( defp render_container(
%{assigns: %{live_action: live_action, show_used: show_used}} = socket, %{assigns: %{type: type, live_action: live_action}} = socket,
id, id,
current_user current_user
) do ) do
%{name: container_name} = %{name: container_name} = container = Containers.get_container!(id, current_user)
container = ammo_groups = Ammo.list_ammo_groups_for_container(container, type, current_user)
Containers.get_container!(id, current_user) original_counts = ammo_groups |> Ammo.get_original_counts(current_user)
|> Repo.preload([:tags], force: true) cprs = ammo_groups |> Ammo.get_cprs(current_user)
last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user)
ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used)
page_title = page_title =
case live_action do case live_action do
action when action in [:show, :table] -> container_name :show -> container_name
:edit -> gettext("Edit %{name}", name: container_name) :edit -> gettext("Edit %{name}", name: container_name)
:edit_tags -> gettext("Edit %{name} tags", name: container_name) :edit_tags -> gettext("Edit %{name} tags", name: container_name)
end end
socket |> assign(container: container, ammo_groups: ammo_groups, page_title: page_title) socket
|> assign(
container: container,
round_count: Ammo.get_round_count_for_container!(container, current_user),
ammo_groups_count: Ammo.get_ammo_groups_count_for_container!(container, current_user),
ammo_groups: ammo_groups,
original_counts: original_counts,
cprs: cprs,
last_used_dates: last_used_dates,
page_title: page_title
)
end end
@spec render_container(Socket.t()) :: Socket.t() @spec render_container(Socket.t()) :: Socket.t()

View File

@ -3,50 +3,36 @@
<%= @container.name %> <%= @container.name %>
</h1> </h1>
<%= if @container.desc do %> <span :if={@container.desc} class="rounded-lg title text-lg">
<span class="rounded-lg title text-lg">
<%= gettext("Description:") %> <%= gettext("Description:") %>
<%= @container.desc %> <%= @container.desc %>
</span> </span>
<% end %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Type:") %> <%= gettext("Type:") %>
<%= @container.type %> <%= @container.type %>
</span> </span>
<%= if @container.location do %> <span :if={@container.location} class="rounded-lg title text-lg">
<span class="rounded-lg title text-lg">
<%= gettext("Location:") %> <%= gettext("Location:") %>
<%= @container.location %> <%= @container.location %>
</span> </span>
<% end %>
<%= unless @ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= if @show_used do %>
<%= gettext("Total packs:") %>
<% else %>
<%= gettext("Packs:") %> <%= gettext("Packs:") %>
<% end %> <%= @ammo_groups_count %>
<%= Enum.count(@ammo_groups) %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= if @show_used do %>
<%= gettext("Total rounds:") %>
<% else %>
<%= gettext("Rounds:") %> <%= gettext("Rounds:") %>
<% end %> <%= @round_count %>
<%= @container |> Containers.get_container_rounds!() %>
</span> </span>
<% end %>
<div class="flex space-x-4 justify-center items-center text-primary-600"> <div class="flex space-x-4 justify-center items-center text-primary-600">
<.link <.link
patch={Routes.container_show_path(Endpoint, :edit, @container)} patch={Routes.container_show_path(Endpoint, :edit, @container)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa="edit" aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -58,7 +44,9 @@
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name)
} }
data-qa="delete" aria-label={
dgettext("actions", "Delete %{container_name}", container_name: @container.name)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
@ -82,9 +70,7 @@
</div> </div>
<% else %> <% else %>
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<%= for tag <- @container.tags do %> <.simple_tag_card :for={tag <- @container.tags} tag={tag} />
<.simple_tag_card tag={tag} />
<% end %>
<div class="mx-4 my-2"> <div class="mx-4 my-2">
<.link <.link
@ -100,11 +86,29 @@
<hr class="mb-4 hr" /> <hr class="mb-4 hr" />
<div class="flex justify-center items-center space-x-4"> <div class="flex justify-center items-center space-x-4">
<.toggle_button action="toggle_show_used" value={@show_used}> <.form
<span class="title text-lg text-primary-600"> :let={f}
<%= gettext("Show used") %> for={%{}}
</span> as={:ammo_type}
</.toggle_button> phx-change="change_type"
phx-submit="change_type"
class="flex items-center"
>
<%= label(f, :type, gettext("Type"), class: "title text-primary-600 text-lg text-center") %>
<%= select(
f,
:type,
[
{gettext("All"), :all},
{gettext("Rifle"), :rifle},
{gettext("Shotgun"), :shotgun},
{gettext("Pistol"), :pistol}
],
class: "mx-2 my-1 min-w-md input input-primary",
value: @type
) %>
</.form>
<.toggle_button action="toggle_table" value={@view_table}> <.toggle_button action="toggle_table" value={@view_table}>
<span class="title text-lg text-primary-600"> <span class="title text-lg text-primary-600">
@ -125,6 +129,7 @@
id="ammo-type-show-table" id="ammo-type-show-table"
ammo_groups={@ammo_groups} ammo_groups={@ammo_groups}
current_user={@current_user} current_user={@current_user}
show_used={false}
> >
<:ammo_type :let={%{name: ammo_type_name} = ammo_type}> <:ammo_type :let={%{name: ammo_type_name} = ammo_type}>
<.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link">
@ -134,16 +139,22 @@
</.live_component> </.live_component>
<% else %> <% else %>
<div class="flex flex-wrap justify-center items-stretch"> <div class="flex flex-wrap justify-center items-stretch">
<%= for ammo_group <- @ammo_groups do %> <.ammo_group_card
<.ammo_group_card ammo_group={ammo_group} /> :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups}
<% end %> ammo_group={ammo_group}
original_count={Map.fetch!(@original_counts, ammo_group_id)}
cpr={Map.get(@cprs, ammo_group_id)}
last_used_date={Map.get(@last_used_dates, ammo_group_id)}
current_user={@current_user}
/>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</div> </div>
<%= if @live_action in [:edit] do %> <%= case @live_action do %>
<% :edit -> %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.FormComponent} module={CanneryWeb.ContainerLive.FormComponent}
@ -155,9 +166,7 @@
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% end %> <% :edit_tags -> %>
<%= if @live_action == :edit_tags do %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent} module={CanneryWeb.ContainerLive.EditTagsComponent}
@ -166,7 +175,9 @@
action={@live_action} action={@live_action}
container={@container} container={@container}
return_to={Routes.container_show_path(Endpoint, :show, @container)} return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% _ -> %>
<% end %> <% end %>

View File

@ -12,165 +12,6 @@ defmodule CanneryWeb.HomeLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
admins = Accounts.list_users_by_role(:admin) admins = Accounts.list_users_by_role(:admin)
socket = socket |> assign(page_title: gettext("Home"), admins: admins, version: @version) {:ok, socket |> assign(page_title: gettext("Home"), admins: admins, version: @version)}
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl">
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out"
title={gettext("isn't he cute >:3")}
/>
<h1 class="title text-primary-600 text-2xl">
<%= gettext("Welcome to %{name}", name: "Cannery") %>
</h1>
<h2 class="title text-primary-600 text-lg">
<%= gettext("The self-hosted firearm tracker website") %>
</h2>
<hr class="hr" />
<ul class="flex flex-col space-y-4 text-center">
<li class="flex flex-col justify-center items-center
space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Easy to Use:") %>
</b>
<p>
<%= gettext(
"%{name} lets you easily keep an eye on your ammo levels before and after range day",
name: "Cannery"
) %>
</p>
</li>
<li class="flex flex-col justify-center items-center
space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Secure:") %>
</b>
<p>
<%= gettext("Self-host your own instance, or use an instance from someone you trust.") %>
<%= gettext("Your data stays with you, period") %>
</p>
</li>
<li class="flex flex-col justify-center items-center
space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Simple:") %>
</b>
<p>
<%= gettext("Access from any internet-capable device") %>
</p>
</li>
</ul>
<hr class="hr" />
<ul class="flex flex-col space-y-2 text-center justify-center">
<h2 class="title text-primary-600 text-lg">
<%= gettext("Instance Information") %>
</h2>
<li class="flex flex-col justify-center space-x-2">
<b>
<%= gettext("Admins:") %>
</b>
<p>
<%= if @admins |> Enum.empty?() do %>
<.link
href={Routes.user_registration_path(CanneryWeb.Endpoint, :new)}
class="hover:underline"
>
<%= dgettext("prompts", "Register to setup %{name}", name: "Cannery") %>
</.link>
<% else %>
<div class="flex flex-wrap justify-center space-x-2">
<%= for admin <- @admins do %>
<a class="hover:underline" href={"mailto:#{admin.email}"}>
<%= admin.email %>
</a>
<% end %>
</div>
<% end %>
</p>
</li>
<li class="flex flex-row justify-center space-x-2">
<b>Registration:</b>
<p>
<%= Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration]
|> case do
"public" -> gettext("Public Signups")
_ -> gettext("Invite Only")
end %>
</p>
</li>
<li class="flex flex-row justify-center items-center space-x-2">
<b>Version:</b>
<.link
href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md"
class="flex flex-row justify-center items-center space-x-2 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
<p>
<%= @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>
"""
end end
end end

View File

@ -0,0 +1,146 @@
<div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl">
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out"
title={gettext("isn't he cute >:3")}
/>
<h1 class="title text-primary-600 text-2xl">
<%= gettext("Welcome to Cannery") %>
</h1>
<h2 class="title text-primary-600 text-lg">
<%= gettext("The self-hosted firearm tracker website") %>
</h2>
<hr class="hr" />
<ul class="flex flex-col space-y-4 text-center">
<li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Easy to Use:") %>
</b>
<p>
<%= gettext(
"Cannery lets you easily keep an eye on your ammo levels before and after range day"
) %>
</p>
</li>
<li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Secure:") %>
</b>
<p>
<%= gettext("Self-host your own instance, or use an instance from someone you trust.") %>
<%= gettext("Your data stays with you, period") %>
</p>
</li>
<li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap">
<%= gettext("Simple:") %>
</b>
<p>
<%= gettext("Access from any internet-capable device") %>
</p>
</li>
</ul>
<hr class="hr" />
<ul class="flex flex-col space-y-2 text-center justify-center">
<h2 class="title text-primary-600 text-lg">
<%= gettext("Instance Information") %>
</h2>
<li class="flex flex-col justify-center space-x-2">
<b>
<%= gettext("Admins:") %>
</b>
<p>
<%= if @admins |> Enum.empty?() do %>
<.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline">
<%= dgettext("prompts", "Register to setup Cannery") %>
</.link>
<% else %>
<div class="flex flex-wrap justify-center space-x-2">
<.link
:for={%{email: email} <- @admins}
class="hover:underline"
href={"mailto:#{email}"}
>
<%= email %>
</.link>
</div>
<% end %>
</p>
</li>
<li class="flex flex-row justify-center space-x-2">
<b><%= gettext("Registration:") %></b>
<p>
<%= case Accounts.registration_mode() do
:public -> gettext("Public Signups")
:invite_only -> gettext("Invite Only")
end %>
</p>
</li>
<li class="flex flex-row justify-center items-center space-x-2">
<b><%= gettext("Version:") %></b>
<.link
href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md"
class="flex flex-row justify-center items-center space-x-2 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
<p>
<%= @version %>
</p>
<i class="fas fa-md fa-info-circle"></i>
</.link>
</li>
</ul>
<hr class="hr" />
<ul class="flex flex-col space-y-2 text-center justify-center">
<h2 class="title text-primary-600 text-lg">
<%= gettext("Get involved!") %>
</h2>
<li class="flex flex-col justify-center space-x-2">
<.link
class="flex flex-row justify-center items-center space-x-2 hover:underline"
href="https://gitea.bubbletea.dev/shibao/cannery"
target="_blank"
rel="noopener noreferrer"
>
<p><%= gettext("View the source code") %></p>
<i class="fas fa-md fa-code"></i>
</.link>
</li>
<li class="flex flex-col justify-center space-x-2">
<.link
class="flex flex-row justify-center items-center space-x-2 hover:underline"
href="https://weblate.bubbletea.dev/engage/cannery"
target="_blank"
rel="noopener noreferrer"
>
<p><%= gettext("Help translate") %></p>
<i class="fas fa-md fa-language"></i>
</.link>
</li>
<li class="flex flex-col justify-center space-x-2">
<.link
class="flex flex-row justify-center items-center space-x-2 hover:underline"
href="https://gitea.bubbletea.dev/shibao/cannery/issues/new"
target="_blank"
rel="noopener noreferrer"
>
<p><%= gettext("Report bugs or request features") %></p>
<i class="fas fa-md fa-spider"></i>
</.link>
</li>
</ul>
</div>

View File

@ -1,11 +1,11 @@
defmodule CanneryWeb.InviteLive.FormComponent do defmodule CanneryWeb.InviteLive.FormComponent do
@moduledoc """ @moduledoc """
Livecomponent that can update or create an Cannery.Invites.Invite Livecomponent that can update or create an Cannery.Accounts.Invite
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Invites, Invites.Invite}
alias Ecto.Changeset alias Ecto.Changeset
alias Cannery.Accounts.{Invite, Invites, User}
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true

View File

@ -11,13 +11,17 @@
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
> >
<%= if @changeset.action && not @changeset.valid? do %> <div
<div class="invalid-feedback col-span-3 text-center"> :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<% end %>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> <%= label(f, :name, gettext("Name"),
class: "title text-lg text-primary-600",
maxlength: 255
) %>
<%= text_input(f, :name, class: "input input-primary col-span-2") %> <%= text_input(f, :name, class: "input input-primary col-span-2") %>
<%= error_tag(f, :name, "col-span-3") %> <%= error_tag(f, :name, "col-span-3") %>
@ -25,7 +29,7 @@
<%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %>
<%= error_tag(f, :uses_left, "col-span-3") %> <%= error_tag(f, :uses_left, "col-span-3") %>
<span class="col-span-3 text-primary-400 italic text-center"> <span class="col-span-3 text-primary-400 italic text-center">
<%= gettext("Leave \"Uses left\" blank to make invite unlimited") %> <%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %>
</span> </span>
<%= submit(dgettext("actions", "Save"), <%= submit(dgettext("actions", "Save"),

View File

@ -1,26 +1,16 @@
defmodule CanneryWeb.InviteLive.Index do defmodule CanneryWeb.InviteLive.Index do
@moduledoc """ @moduledoc """
Liveview to show a Cannery.Invites.Invite index Liveview to show a Cannery.Accounts.Invite index
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
import CanneryWeb.Components.{InviteCard, UserCard} alias Cannery.Accounts
alias Cannery.{Accounts, Invites, Invites.Invite} alias Cannery.Accounts.{Invite, Invites}
alias CanneryWeb.{Endpoint, HomeLive}
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
@impl true @impl true
def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do def mount(_params, _session, socket) do
socket = {:ok, socket |> display_invites()}
if current_user |> Map.get(:role) == :admin do
socket |> display_invites()
else
prompt = dgettext("errors", "You are not authorized to view this page")
return_to = Routes.live_path(Endpoint, HomeLive)
socket |> put_flash(:error, prompt) |> push_navigate(to: return_to)
end
{:ok, socket}
end end
@impl true @impl true
@ -29,8 +19,8 @@ defmodule CanneryWeb.InviteLive.Index do
end end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket invite = Invites.get_invite!(id, current_user)
|> assign(page_title: gettext("Edit Invite"), invite: Invites.get_invite!(id, current_user)) socket |> assign(page_title: gettext("Edit Invite"), invite: invite)
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
@ -50,7 +40,7 @@ defmodule CanneryWeb.InviteLive.Index do
%{name: invite_name} = %{name: invite_name} =
id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user) 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()} {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
end end
@ -61,10 +51,12 @@ defmodule CanneryWeb.InviteLive.Index do
) do ) do
socket = socket =
Invites.get_invite!(id, current_user) Invites.get_invite!(id, current_user)
|> Invites.update_invite(%{"uses_left" => nil}, current_user) |> Invites.update_invite(%{uses_left: nil}, current_user)
|> case do |> case do
{:ok, %{name: invite_name}} -> {: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() socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} -> {:error, changeset} ->
@ -81,10 +73,12 @@ defmodule CanneryWeb.InviteLive.Index do
) do ) do
socket = socket =
Invites.get_invite!(id, current_user) 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 |> case do
{:ok, %{name: invite_name}} -> {: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() socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} -> {:error, changeset} ->
@ -103,10 +97,12 @@ defmodule CanneryWeb.InviteLive.Index do
socket = socket =
Invites.get_invite!(id, current_user) 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 |> case do
{:ok, %{name: invite_name}} -> {: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() socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} -> {:error, changeset} ->
@ -116,22 +112,17 @@ defmodule CanneryWeb.InviteLive.Index do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("copy_to_clipboard", _params, socket) do def handle_event("copy_to_clipboard", _params, socket) do
prompt = dgettext("prompts", "Copied to clipboard") {:noreply, socket |> put_flash(:info, dgettext("prompts", "Copied to clipboard"))}
{:noreply, socket |> put_flash(:info, prompt)}
end end
@impl true
def handle_event( def handle_event(
"delete_user", "delete_user",
%{"id" => id}, %{"id" => id},
%{assigns: %{current_user: current_user}} = socket %{assigns: %{current_user: current_user}} = socket
) do ) do
%{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user) %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user)
prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email)
prompt = dgettext("prompts", "%{name} deleted succesfully", name: user_email)
{:noreply, socket |> put_flash(:info, prompt) |> display_invites()} {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
end end
@ -144,7 +135,8 @@ defmodule CanneryWeb.InviteLive.Index do
|> Map.get(:admin, []) |> Map.get(:admin, [])
|> Enum.reject(fn %{id: user_id} -> user_id == current_user.id end) |> Enum.reject(fn %{id: user_id} -> user_id == current_user.id end)
use_counts = invites |> Invites.get_use_counts(current_user)
users = all_users |> Map.get(:user, []) users = all_users |> Map.get(:user, [])
socket |> assign(invites: invites, admins: admins, users: users) socket |> assign(invites: invites, use_counts: use_counts, admins: admins, users: users)
end end
end end

View File

@ -1,4 +1,4 @@
<div class="w-full flex flex-col space-y-8 justify-center items-center"> <div class="mx-auto flex flex-col justify-center items-center space-y-4 max-w-3xl">
<h1 class="title text-2xl title-primary-500"> <h1 class="title text-2xl title-primary-500">
<%= gettext("Invites") %> <%= gettext("Invites") %>
</h1> </h1>
@ -18,15 +18,22 @@
</.link> </.link>
<% end %> <% end %>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch"> <div class="flex flex-col justify-center items-stretch space-y-4">
<%= for invite <- @invites do %> <.invite_card
<.invite_card invite={invite}> :for={invite <- @invites}
invite={invite}
current_user={@current_user}
use_count={Map.get(@use_counts, invite.id)}
>
<:code_actions> <:code_actions>
<form phx-submit="copy_to_clipboard"> <form phx-submit="copy_to_clipboard">
<button <button
type="submit" type="submit"
class="mx-2 my-1 btn btn-primary" class="mx-2 my-1 btn btn-primary"
phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")} phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")}
aria-label={
dgettext("actions", "Copy invite link for %{invite_name}", invite_name: invite.name)
}
> >
<%= dgettext("actions", "Copy to clipboard") %> <%= dgettext("actions", "Copy to clipboard") %>
</button> </button>
@ -35,7 +42,9 @@
<.link <.link
patch={Routes.invite_index_path(Endpoint, :edit, invite)} patch={Routes.invite_index_path(Endpoint, :edit, invite)}
class="text-primary-600 link" class="text-primary-600 link"
data-qa={"edit-#{invite.id}"} aria-label={
dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -46,42 +55,41 @@
phx-click="delete_invite" phx-click="delete_invite"
phx-value-id={invite.id} phx-value-id={invite.id}
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to delete the invite for %{name}?", dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?",
name: invite.name invite_name: invite.name
) )
} }
data-qa={"delete-#{invite.id}"} aria-label={
dgettext("actions", "Delete invite for %{invite_name}", invite_name: invite.name)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
<%= if invite.disabled_at |> is_nil() do %> <.link
<a href="#" class="btn btn-primary" phx-click="disable_invite" phx-value-id={invite.id}> href="#"
<%= dgettext("actions", "Disable") %> class="btn btn-primary"
</a> phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"}
<% else %> phx-value-id={invite.id}
<a href="#" class="btn btn-primary" phx-click="enable_invite" phx-value-id={invite.id}> >
<%= dgettext("actions", "Enable") %> <%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %>
</a> </.link>
<% end %>
<%= if invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil()) do %> <.link
<a :if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())}
href="#" href="#"
class="btn btn-primary" class="btn btn-primary"
phx-click="set_unlimited" phx-click="set_unlimited"
phx-value-id={invite.id} phx-value-id={invite.id}
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to make %{name} unlimited?", dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?",
name: invite.name invite_name: invite.name
) )
} }
> >
<%= dgettext("actions", "Set Unlimited") %> <%= dgettext("actions", "Set Unlimited") %>
</a> </.link>
<% end %>
</.invite_card> </.invite_card>
<% end %>
</div> </div>
<%= unless @admins |> Enum.empty?() do %> <%= unless @admins |> Enum.empty?() do %>
@ -91,9 +99,8 @@
<%= gettext("Admins") %> <%= gettext("Admins") %>
</h1> </h1>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch"> <div class="flex flex-col justify-center items-stretch space-y-4">
<%= for admin <- @admins do %> <.user_card :for={admin <- @admins} user={admin}>
<.user_card user={admin}>
<.link <.link
href="#" href="#"
class="text-primary-600 link" class="text-primary-600 link"
@ -110,7 +117,6 @@
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
</.user_card> </.user_card>
<% end %>
</div> </div>
<% end %> <% end %>
@ -121,9 +127,8 @@
<%= gettext("Users") %> <%= gettext("Users") %>
</h1> </h1>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch"> <div class="flex flex-col justify-center items-stretch space-y-4">
<%= for user <- @users do %> <.user_card :for={user <- @users} user={user}>
<.user_card user={user}>
<.link <.link
href="#" href="#"
class="text-primary-600 link" class="text-primary-600 link"
@ -140,13 +145,11 @@
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
</.user_card> </.user_card>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
<%= if @live_action in [:new, :edit] do %> <.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}>
<.modal return_to={Routes.invite_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.InviteLive.FormComponent} module={CanneryWeb.InviteLive.FormComponent}
id={@invite.id || :new} id={@invite.id || :new}
@ -156,5 +159,4 @@
return_to={Routes.invite_index_path(Endpoint, :index)} return_to={Routes.invite_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% end %>

View File

@ -1,128 +0,0 @@
defmodule CanneryWeb.LiveHelpers do
@moduledoc """
Contains common helper functions for liveviews
"""
import Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders a live component inside a modal.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
<.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new}
title={@page_title}
action={@live_action}
return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
<%= schema.singular %>: @<%= schema.singular %>
/>
</.modal>
"""
def modal(assigns) do
~H"""
<.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>
</.link>
<div
id="modal"
class="fixed z-10 left-0 top-0 pointer-events-none
w-full h-full overflow-hidden
p-4 sm:p-8 flex flex-col justify-center items-center"
>
<div
id="modal-content"
class="fade-in-scale w-full max-w-3xl relative
pointer-events-auto overflow-hidden
px-8 py-4 sm:py-8 flex flex-col justify-center items-center
flex flex-col justify-start items-center
bg-white border-2 rounded-lg"
>
<.link
patch={@return_to}
id="close"
class="absolute top-8 right-10
text-gray-500 hover:text-gray-800
transition-all duration-500 ease-in-out"
phx_remove={hide_modal()}
>
<i class="fa-fw fa-lg fas fa-times"></i>
</.link>
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
<%= render_slot(@inner_block) %>
</div>
</div>
</div>
"""
end
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

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