147 Commits

Author SHA1 Message Date
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
181 changed files with 17224 additions and 7926 deletions

View File

@ -13,20 +13,24 @@ steps:
mount:
- _build
- deps
- assets/node_modules/
- .npm
- .mix
- name: test
image: elixir:1.14.1-alpine
environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld
MIX_HOME: /drone/src/.mix
MIX_ARCHIVES: /drone/src/.mix/archives
MIX_ENV: test
commands:
- apk add --no-cache build-base npm git python3
- mix local.rebar --force
- mix local.hex --force
- apk add --no-cache build-base npm git
- mix local.rebar --force --if-missing
- mix local.hex --force --if-missing
- mix deps.get
- mix deps.compile
- npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
- npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract
- mix test.all
@ -76,7 +80,8 @@ steps:
mount:
- _build
- deps
- assets/node_modules/
- .npm
- .mix
services:
- 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.
npm-debug.log
# Ignore assets that are produced by build tools.
# The directory NPM downloads your dependencies sources to.
/assets/node_modules/
# Since we are building assets from assets/,

View File

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

View File

@ -1,7 +1,36 @@
# v0.8.5
- Add link in readme to github mirror
- Fix tables unable to sort on empty dates
- Only show historical ammo type information when displaying "Show used"
- Fix even more accessibility issues
# v0.8.4
- Improve accessibility
- Code quality improvements
- Fix dead link of example bullet abbreviations
- Fix inaccurate error message when updating shot records
- Fix tables not sorting dates correctly
- Fix dates displaying incorrectly
- Fix container table not displaying all fields
- Fix textareas resizing when typing in them
# v0.8.3
- Improve some styles
- Improve server log
- Various minor improvements
# v0.8.2
- Fix bug with public registration
- Improve templates
- Improve invites, record usage
- Fix padding on more pages when using chrome
- Add oban metrics to server log and live dashboard
# v0.8.1
- Update dependencies
- Show topbar on form submit/page refresh
- Make loading/reconnection less intrusive
- Add QR code for invite link
# v0.8.0
- Add search to catalog, ammo, container, tag and range index pages

View File

@ -63,7 +63,8 @@ And as always, thank you!
[`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/).
- `Dockerfile` and example `docker-compose.yml`
- 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
@ -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`.
- `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`.
- Available options: `en_US`, `de`, and `fr`
- Available options: `en_US`, `de`, `fr`, and `es`
## `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
public. Set to `public` to enable public registration. Defaults to `invite`.
- `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_PORT`: The port for your SMTP relay. Defaults to `587`.
- `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.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

View File

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

View File

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

View File

@ -24,26 +24,18 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from '../vendor/topbar'
import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart'
import Alpine from 'alpinejs'
import Date from './date'
import DateTime from './datetime'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, {
dom: {
onBeforeElUpdated (from, to) {
if (from._x_dataStack) { window.Alpine.clone(from, to) }
}
},
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
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
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": " ",
"license": "MIT",
"engines": {
"node": "18.12.1",
"npm": "8.19.2"
"node": "v18.9.1",
"npm": "8.19.1"
},
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
@ -13,37 +13,37 @@
"test": "standard"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1",
"alpinejs": "^3.10.2",
"chart.js": "^3.9.1",
"chartjs-adapter-date-fns": "^2.0.0",
"@fortawesome/fontawesome-free": "^6.3.0",
"chart.js": "^4.2.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.29.3",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.0.1"
"topbar": "^2.0.1"
},
"devDependencies": {
"@babel/core": "^7.17.10",
"@babel/preset-env": "^7.17.10",
"autoprefixer": "^10.4.7",
"babel-loader": "^8.2.5",
"copy-webpack-plugin": "^10.2.4",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.6.0",
"postcss": "^8.4.13",
"postcss-import": "^14.1.0",
"postcss-loader": "^6.2.1",
"postcss-preset-env": "^7.5.0",
"sass": "^1.56.0",
"sass-loader": "^12.6.0",
"mini-css-extract-plugin": "^2.7.5",
"npm-check-updates": "^16.7.12",
"postcss": "^8.4.21",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.0.1",
"sass": "^1.59.3",
"sass-loader": "^13.2.1",
"standard": "^17.0.0",
"tailwindcss": "^3.0.24",
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.72.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0"
"tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.7",
"webpack": "^5.76.2",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.1"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -4,7 +4,8 @@ defmodule Cannery.ActivityLog do
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo}
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.Multi
@doc """
@ -31,8 +32,10 @@ defmodule Cannery.ActivityLog do
Repo.all(
from sg in ShotGroup,
left_join: ag in assoc(sg, :ammo_group),
left_join: at in assoc(ag, :ammo_type),
left_join: ag in AmmoGroup,
on: sg.ammo_group_id == ag.id,
left_join: at in AmmoType,
on: ag.ammo_type_id == at.id,
where: sg.user_id == ^user_id,
where:
fragment(
@ -61,6 +64,18 @@ defmodule Cannery.ActivityLog do
)
end
@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
@doc """
Gets a single shot_group.
@ -107,9 +122,15 @@ defmodule Cannery.ActivityLog do
)
|> Multi.run(
:ammo_group,
fn repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
ammo_group =
Repo.one(
from ag in AmmoGroup,
where: ag.id == ^ammo_group_id,
where: ag.user_id == ^user_id
)
{:ok, ammo_group}
end
)
|> Multi.update(
@ -220,4 +241,112 @@ defmodule Cannery.ActivityLog do
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
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

View File

@ -6,7 +6,7 @@ defmodule Cannery.ActivityLog.ShotGroup do
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo}
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
@ -24,25 +24,23 @@ defmodule Cannery.ActivityLog.ShotGroup do
field :date, :date
field :notes, :string
belongs_to :user, User
belongs_to :ammo_group, AmmoGroup
field :user_id, :binary_id
field :ammo_group_id, :binary_id
timestamps()
end
@type t :: %ShotGroup{
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
ammo_group: AmmoGroup.t() | nil,
ammo_group_id: AmmoGroup.id(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_shot_group :: %ShotGroup{}
@type new_shot_group :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_group())
@ -58,42 +56,47 @@ defmodule Cannery.ActivityLog.ShotGroup do
%User{id: user_id},
%AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group,
attrs
)
when not (user_id |> is_nil()) and not (ammo_group_id |> is_nil()) do
) do
shot_group
|> change(user_id: user_id)
|> change(ammo_group_id: ammo_group_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
|> validate_create_shot_group_count(ammo_group)
|> validate_required([:count, :date, :ammo_group_id, :user_id])
|> validate_required([:date, :ammo_group_id, :user_id])
end
def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
|> 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"))
end
defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do
if changeset |> Changeset.get_field(:count) > ammo_group_count do
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count)
changeset |> Changeset.add_error(:count, error)
else
changeset
case changeset |> Changeset.get_field(:count) do
nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > ammo_group_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds",
count: ammo_group_count - 1
)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset()
def update_changeset(
%ShotGroup{user_id: user_id} = shot_group,
%User{id: user_id} = user,
attrs
)
when not (user_id |> is_nil()) do
def update_changeset(%__MODULE__{} = shot_group, user, attrs) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
@ -103,26 +106,21 @@ defmodule Cannery.ActivityLog.ShotGroup do
defp validate_update_shot_group_count(
changeset,
%ShotGroup{count: count} = shot_group,
%User{id: user_id}
)
when not (user_id |> is_nil()) do
%{ammo_group: %AmmoGroup{count: ammo_group_count, user_id: ^user_id}} =
shot_group |> Repo.preload(:ammo_group)
%__MODULE__{ammo_group_id: ammo_group_id, count: count},
user
) do
%{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user)
new_shot_group_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_group_count - count
cond do
shot_diff_to_add > ammo_group_count ->
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count)
changeset |> Changeset.add_error(:count, error)
if shot_diff_to_add > ammo_group_count do
error =
dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count)
new_shot_group_count <= 0 ->
changeset |> Changeset.add_error(:count, dgettext("errors", "Count must be at least 1"))
true ->
changeset
changeset |> Changeset.add_error(:count, error)
else
changeset
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
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers, Containers.Container}
alias Cannery.Ammo.AmmoType
alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
@ -33,15 +33,13 @@ defmodule Cannery.Ammo.AmmoGroup do
field :purchased_on, :date
belongs_to :ammo_type, AmmoType
belongs_to :container, Container
belongs_to :user, User
has_many :shot_groups, ShotGroup
field :container_id, :binary_id
field :user_id, :binary_id
timestamps()
end
@type t :: %AmmoGroup{
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
@ -50,14 +48,12 @@ defmodule Cannery.Ammo.AmmoGroup do
purchased_on: Date.t(),
ammo_type: AmmoType.t() | nil,
ammo_type_id: AmmoType.id(),
container: Container.t() | nil,
container_id: Container.id(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_group :: %AmmoGroup{}
@type new_ammo_group :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_group())
@ -76,8 +72,7 @@ defmodule Cannery.Ammo.AmmoGroup do
%User{id: user_id},
attrs
)
when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and
not (user_id |> is_nil()) do
when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do
ammo_group
|> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id)

View File

@ -8,7 +8,7 @@ defmodule Cannery.Ammo.AmmoType do
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.Ammo.AmmoGroup
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
@ -64,14 +64,14 @@ defmodule Cannery.Ammo.AmmoType do
field :manufacturer, :string
field :upc, :string
belongs_to :user, User
field :user_id, :binary_id
has_many :ammo_groups, AmmoGroup
timestamps()
end
@type t :: %AmmoType{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t() | nil,
@ -95,12 +95,11 @@ defmodule Cannery.Ammo.AmmoType do
manufacturer: String.t() | nil,
upc: String.t() | nil,
user_id: User.id(),
user: User.t() | nil,
ammo_groups: [AmmoGroup.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_type :: %AmmoType{}
@type new_ammo_type :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_type())

View File

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

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 Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag}
alias Cannery.Containers.{Container, ContainerTag}
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.Changeset
@container_preloads [:tags]
@doc """
Returns the list of containers.
@ -28,11 +30,9 @@ defmodule Cannery.Containers do
as: :c,
left_join: t in assoc(c, :tags),
as: :t,
left_join: ag in assoc(c, :ammo_groups),
as: :ag,
where: c.user_id == ^user_id,
order_by: c.name,
preload: [tags: t, ammo_groups: ag]
preload: ^@container_preloads
)
|> list_containers_search(search)
|> Repo.all()
@ -106,12 +106,10 @@ defmodule Cannery.Containers do
def get_container!(id, %User{id: user_id}) do
Repo.one!(
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.id == ^id,
order_by: c.name,
preload: [tags: t, ammo_groups: ag]
preload: ^@container_preloads
)
end
@ -130,7 +128,19 @@ defmodule Cannery.Containers do
@spec create_container(attrs :: map(), User.t()) ::
{:ok, Container.t()} | {:error, Container.changeset()}
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
@doc """
@ -148,7 +158,13 @@ defmodule Cannery.Containers do
@spec update_container(Container.t(), User.t(), attrs :: map()) ::
{:ok, Container.t()} | {:error, Container.changeset()}
def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do
container |> Container.update_changeset(attrs) |> Repo.update()
container
|> Container.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end
@doc """
@ -173,7 +189,12 @@ defmodule Cannery.Containers do
)
|> case do
0 ->
container |> Repo.delete()
container
|> Repo.delete()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
_amount ->
error = dgettext("errors", "Container must be empty before deleting")
@ -214,8 +235,11 @@ defmodule Cannery.Containers do
%Container{user_id: user_id} = container,
%Tag{user_id: user_id} = tag,
%User{id: user_id}
),
do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!()
) do
%ContainerTag{}
|> ContainerTag.create_changeset(tag, container)
|> Repo.insert!()
end
@doc """
Removes a tag from a container
@ -226,45 +250,175 @@ defmodule Cannery.Containers do
%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!(
%Container{id: container_id, user_id: user_id},
%Tag{id: tag_id, user_id: user_id},
%User{id: user_id}
) do
{count, _} =
{count, results} =
Repo.delete_all(
from ct in ContainerTag,
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
@doc """
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
Gets a single tag.
## 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()
def get_container_ammo_group_count!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.reject(fn %{count: count} -> count == 0 end)
|> Enum.count()
@spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, :not_found}
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, :not_found}
tag -> {:ok, tag}
end
end
@doc """
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
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_container_rounds!(Container.t()) :: non_neg_integer()
def get_container_rounds!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.map(fn %{count: count} -> count end)
|> Enum.sum()
@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,
where: t.user_id == ^user_id
)
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

View File

@ -6,8 +6,7 @@ defmodule Cannery.Containers.Container do
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Cannery.Containers.{Container, ContainerTag}
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag}
alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag}
@derive {Jason.Encoder,
only: [
@ -26,28 +25,25 @@ defmodule Cannery.Containers.Container do
field :location, :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
timestamps()
end
@type t :: %Container{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t(),
location: String.t(),
type: String.t(),
user: User.t(),
user_id: User.id(),
ammo_groups: [AmmoGroup.t()] | nil,
tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container :: %Container{}
@type new_container :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container())

View File

@ -1,12 +1,12 @@
defmodule Cannery.Containers.ContainerTag do
@moduledoc """
Thru-table struct for associating Cannery.Containers.Container and
Cannery.Tags.Tag.
Cannery.Containers.Tag.
"""
use Ecto.Schema
import Ecto.Changeset
alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag}
alias Cannery.Containers.{Container, Tag}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@ -18,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do
timestamps()
end
@type t :: %ContainerTag{
@type t :: %__MODULE__{
id: id(),
container: Container.t(),
container_id: Container.id(),
@ -27,7 +27,7 @@ defmodule Cannery.Containers.ContainerTag do
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container_tag :: %ContainerTag{}
@type new_container_tag :: %__MODULE__{}
@type id :: UUID.t()
@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 """
Tags are added to containers to help organize, and can include custom-defined
text and bg colors.
@ -6,8 +6,8 @@ defmodule Cannery.Tags.Tag do
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Tags.Tag}
@derive {Jason.Encoder,
only: [
@ -23,22 +23,21 @@ defmodule Cannery.Tags.Tag do
field :bg_color, :string
field :text_color, :string
belongs_to :user, User
field :user_id, :binary_id
timestamps()
end
@type t :: %Tag{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
bg_color: String.t(),
text_color: String.t(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_tag() :: %Tag{}
@type new_tag() :: %__MODULE__{}
@type id() :: UUID.t()
@type changeset() :: Changeset.t(t() | new_tag())

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
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
defp load_app do
@ -20,7 +22,8 @@ defmodule Cannery.Release do
load_app()
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

View File

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

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

View File

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

View File

@ -12,11 +12,12 @@
phx-change="validate"
phx-submit="save"
>
<%= if @changeset.action && not @changeset.valid? do %>
<div class="invalid-feedback col-span-3 text-center">
<%= changeset_errors(@changeset) %>
</div>
<% end %>
<div
:if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :ammo_left,
@ -36,9 +37,11 @@
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes,
id: "add-shot-group-form-notes",
class: "input input-primary col-span-2",
placeholder: "Really great weather",
phx_hook: "MaintainAttrs"
placeholder: gettext("Really great weather"),
phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %>
<%= 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,8 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
A component that displays a list of ammo groups
"""
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 Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -50,12 +51,12 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
if actions == [] do
[]
else
[%{label: nil, key: :actions, sortable: false}]
[%{label: gettext("Actions"), key: :actions, sortable: false}]
end
columns = [
%{label: gettext("Purchased on"), key: :purchased_on},
%{label: gettext("Last used on"), key: :used_up_on} | columns
%{label: gettext("Purchased on"), key: :purchased_on, type: ComparableDate},
%{label: gettext("Last used on"), key: :used_up_on, type: ComparableDate} | columns
]
columns =
@ -94,13 +95,15 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
ammo_type: ammo_type,
columns: columns,
container: container,
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),
actions: actions,
range: range
}
rows =
ammo_groups
|> Repo.preload([:ammo_type, :container])
|> Enum.map(fn ammo_group ->
ammo_group |> get_row_data_for_ammo_group(extra_data)
end)
@ -124,8 +127,6 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
@spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map()
defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do
ammo_group = ammo_group |> Repo.preload([:ammo_type, :container])
columns
|> Map.new(fn %{key: key} ->
{key, get_value_for_key(key, ammo_group, additional_data)}
@ -150,30 +151,23 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil}
defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data),
do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2))
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on}, _additional_data) do
assigns = %{purchased_on: purchased_on}
do: gettext("$%{amount}", amount: display_currency(price_paid))
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do
{purchased_on,
~H"""
<%= @purchased_on |> display_date() %>
<.date id={"#{@id}-purchased-on"} date={@purchased_on} />
"""}
end
defp get_value_for_key(:used_up_on, ammo_group, _additional_data) do
last_shot_group_date =
case ammo_group |> Ammo.get_last_used_shot_group() do
%{date: last_shot_group_date} -> last_shot_group_date
_no_shot_groups -> nil
end
defp get_value_for_key(:used_up_on, %{id: ammo_group_id}, %{last_used_dates: last_used_dates}) do
last_used_date = last_used_dates |> Map.get(ammo_group_id)
assigns = %{id: ammo_group_id, last_used_date: last_used_date}
assigns = %{last_shot_group_date: last_shot_group_date}
{last_shot_group_date,
{last_used_date,
~H"""
<%= if @last_shot_group_date do %>
<%= @last_shot_group_date |> display_date() %>
<%= if @last_used_date do %>
<.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %>
<%= gettext("Never used") %>
<% end %>
@ -189,8 +183,11 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
"""}
end
defp get_value_for_key(:remaining, ammo_group, _additional_data),
do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining())
defp get_value_for_key(:remaining, ammo_group, %{current_user: current_user}),
do:
gettext("%{percentage}%",
percentage: ammo_group |> Ammo.get_percentage_remaining(current_user)
)
defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do
assigns = %{actions: actions, ammo_group: ammo_group}
@ -204,31 +201,40 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
defp get_value_for_key(
:container,
%{container: %{name: container_name}} = ammo_group,
%{container: container}
%{container_id: container_id} = ammo_group,
%{container: container, current_user: current_user}
) do
assigns = %{container: container, ammo_group: ammo_group}
assigns = %{
container:
%{name: container_name} = container_id |> Containers.get_container!(current_user),
container_block: container,
ammo_group: ammo_group
}
{container_name,
~H"""
<%= render_slot(@container, @ammo_group) %>
<%= render_slot(@container_block, {@ammo_group, @container}) %>
"""}
end
defp get_value_for_key(:original_count, ammo_group, _additional_data),
do: ammo_group |> Ammo.get_original_count()
defp get_value_for_key(: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),
do: gettext("No cost information")
defp get_value_for_key(:cpr, ammo_group, _additional_data) do
gettext("$%{amount}",
amount: ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2)
)
defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do
gettext("$%{amount}", amount: display_currency(Map.fetch!(cprs, ammo_group_id)))
end
defp get_value_for_key(:count, %{count: count}, _additional_data),
do: if(count == 0, do: gettext("Empty"), else: count)
defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end

View File

@ -3,7 +3,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
A component that displays a list of ammo type
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType}
alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.AmmoType}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -103,13 +103,13 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
[
%{
label: gettext("Used packs"),
key: :used_ammo_count,
type: :used_ammo_count
key: :used_packs_count,
type: :used_packs_count
},
%{
label: gettext("Total ever packs"),
key: :historical_ammo_count,
type: :historical_ammo_count
key: :historical_packs_count,
type: :historical_packs_count
}
]
else
@ -118,10 +118,38 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
)
|> Kernel.++([
%{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid},
%{label: nil, key: "actions", type: :actions, sortable: false}
%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}
])
extra_data = %{actions: actions, current_user: current_user}
round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user)
used_counts =
show_used && ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user)
historical_round_counts =
show_used && ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user)
packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user)
historical_packs_count =
show_used && ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true)
used_packs_count =
show_used && ammo_types |> Ammo.get_used_ammo_groups_count_for_types(current_user)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(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_packs_count: used_packs_count,
historical_packs_count: historical_packs_count,
average_costs: average_costs
}
rows =
ammo_types
@ -156,28 +184,44 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
defp get_ammo_type_value(:boolean, key, ammo_type, _other_data),
do: ammo_type |> Map.get(key) |> humanize()
defp get_ammo_type_value(:round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user)
defp get_ammo_type_value(:round_count, _key, %{id: ammo_type_id}, %{round_counts: round_counts}),
do: Map.get(round_counts, ammo_type_id)
defp get_ammo_type_value(:historical_round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user)
defp get_ammo_type_value(
:historical_round_count,
_key,
%{id: ammo_type_id},
%{historical_round_counts: historical_round_counts}
),
do: Map.get(historical_round_counts, ammo_type_id)
defp get_ammo_type_value(:used_round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user)
defp get_ammo_type_value(:used_round_count, _key, %{id: ammo_type_id}, %{
used_counts: used_counts
}),
do: Map.get(used_counts, ammo_type_id)
defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true)
defp get_ammo_type_value(
:historical_packs_count,
_key,
%{id: ammo_type_id},
%{historical_packs_count: historical_packs_count}
),
do: Map.get(historical_packs_count, ammo_type_id)
defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user)
defp get_ammo_type_value(:used_packs_count, _key, %{id: ammo_type_id}, %{
used_packs_count: used_packs_count
}),
do: Map.get(used_packs_count, ammo_type_id)
defp get_ammo_type_value(:ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user)
defp get_ammo_type_value(:ammo_count, _key, %{id: ammo_type_id}, %{packs_count: packs_count}),
do: Map.get(packs_count, ammo_type_id)
defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, %{current_user: current_user}) do
case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do
defp get_ammo_type_value(:avg_price_paid, _key, %{id: ammo_type_id}, %{
average_costs: average_costs
}) do
case Map.get(average_costs, ammo_type_id) do
nil -> gettext("No cost information")
count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2))
count -> gettext("$%{amount}", amount: display_currency(count))
end
end
@ -185,11 +229,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
assigns = %{ammo_type: ammo_type}
~H"""
<.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
class="link"
data-qa={"view-name-#{@ammo_type.id}"}
>
<.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link">
<%= @ammo_type.name %>
</.link>
"""
@ -206,4 +246,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
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)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
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
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Containers, Containers.Container, Repo}
alias CanneryWeb.Components.TagCard
alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -46,11 +45,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
%{label: gettext("Name"), key: :name, type: :string},
%{label: gettext("Description"), key: :desc, type: :string},
%{label: gettext("Location"), key: :location, type: :string},
%{label: gettext("Type"), key: :type, type: :string},
%{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :string},
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: nil, key: :actions, sortable: false, type: :actions}
%{label: gettext("Type"), key: :type, type: :string}
]
|> Enum.filter(fn %{key: key, type: type} ->
# 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)
end)
end)
|> Enum.concat([
%{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :integer},
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
])
extra_data = %{
current_user: current_user,
tag_actions: tag_actions,
actions: actions
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 =
@ -101,8 +104,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
@spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map()
defp get_row_data_for_container(container, columns, extra_data) do
container = container |> Repo.preload([:ammo_groups, :tags])
columns
|> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end)
end
@ -121,25 +122,27 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
"""}
end
defp get_value_for_key(:packs, container, _extra_data) do
container |> Containers.get_container_ammo_group_count!()
defp get_value_for_key(:packs, %{id: container_id}, %{pack_count: pack_count}) do
pack_count |> Map.get(container_id, 0)
end
defp get_value_for_key(:rounds, container, _extra_data) do
container |> Containers.get_container_rounds!()
defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do
round_count |> Map.get(container_id, 0)
end
defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do
assigns = %{tag_actions: tag_actions, container: container}
{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"""
<div class="flex flex-wrap justify-center items-center">
<%= unless @container.tags |> Enum.empty?() do %>
<%= for tag <- @container.tags do %>
<TagCard.simple_tag_card tag={tag} />
<% end %>
<% end %>
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= render_slot(@tag_actions, @container) %>
</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, 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 :show_container, :boolean, default: false
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,71 @@
<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={@show_container && Containers.get_container!(@ammo_group.container_id, @current_user)}
class="rounded-lg title text-lg"
>
<%= gettext("Container:") %>
<.link
navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container_id)}
class="link"
>
<%= Containers.get_container!(@ammo_group.container_id, @current_user).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,42 @@
<.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-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>

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

View File

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

View File

@ -33,7 +33,8 @@ defmodule CanneryWeb.Components.TableComponent do
optional(:class) => String.t(),
optional(:row_class) => String.t(),
optional(:alternate_row_class) => String.t(),
optional(:sortable) => false
optional(:sortable) => false,
optional(:type) => module()
}),
required(:rows) =>
list(%{
@ -60,7 +61,8 @@ defmodule CanneryWeb.Components.TableComponent do
:asc
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
@ -68,6 +70,7 @@ defmodule CanneryWeb.Components.TableComponent do
|> assign(
columns: columns,
rows: rows,
key: initial_key,
last_sort_key: initial_key,
sort_mode: initial_sort_mode
)
@ -81,7 +84,14 @@ defmodule CanneryWeb.Components.TableComponent do
def handle_event(
"sort_by",
%{"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
key = key |> String.to_existing_atom()
@ -92,11 +102,28 @@ defmodule CanneryWeb.Components.TableComponent do
{_new_sort_key, _last_sort_mode} -> :asc
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)}
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
|> Enum.sort_by(
fn row ->

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,6 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
{:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()}
end
@impl true
def handle_event(
"toggle_staged",
%{"ammo_group_id" => id},
@ -105,12 +104,10 @@ defmodule CanneryWeb.AmmoGroupLive.Index do
{:noreply, socket |> display_ammo_groups()}
end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()}
end
@impl true
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))}
end

View File

@ -3,12 +3,13 @@
<%= gettext("Ammo") %>
</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 %>
<h2
:if={@ammo_groups |> Enum.empty?() and @search |> is_nil()}
class="title text-xl text-primary-600"
>
<%= gettext("No Ammo") %>
<%= display_emoji("😔") %>
</h2>
<%= cond do %>
<% @containers_count == 0 -> %>
@ -44,15 +45,16 @@
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl">
<.form
:let={f}
for={:search}
for={%{}}
as={:search}
phx-change="search"
phx-submit="search"
class="grow self-stretch flex flex-col items-stretch"
data-qa="ammo_group_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search ammo")
) %>
@ -90,7 +92,9 @@
phx-click="toggle_staged"
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>
<.link
@ -101,7 +105,7 @@
</.link>
</div>
</: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">
<.link
navigate={Routes.container_show_path(Endpoint, :show, container)}
@ -114,16 +118,20 @@
patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)}
class="mx-2 my-1 text-sm btn btn-primary"
>
<%= gettext("Move ammo") %>
<%= dgettext("actions", "Move ammo") %>
</.link>
</div>
</: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">
<.link
navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)}
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>
</.link>
@ -131,7 +139,11 @@
<.link
patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)}
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>
</.link>
@ -139,7 +151,11 @@
<.link
patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)}
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>
</.link>
@ -150,7 +166,11 @@
phx-click="delete"
phx-value-id={ammo_group.id}
data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")}
data-qa={"delete-#{ammo_group.id}"}
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>
</.link>
@ -160,8 +180,8 @@
<% end %>
</div>
<%= cond do %>
<% @live_action in [:new, :edit, :clone] -> %>
<%= case @live_action do %>
<% create when create in [:new, :edit, :clone] -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.AmmoGroupLive.FormComponent}
@ -173,7 +193,7 @@
current_user={@current_user}
/>
</.modal>
<% @live_action == :add_shot_group -> %>
<% :add_shot_group -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.Components.AddShotGroupComponent}
@ -185,7 +205,7 @@
current_user={@current_user}
/>
</.modal>
<% @live_action == :move -> %>
<% :move -> %>
<.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.Components.MoveAmmoGroupComponent}
@ -197,6 +217,5 @@
current_user={@current_user}
/>
</.modal>
<% true -> %>
<%= nil %>
<% _ -> %>
<% end %>

View File

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

View File

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

View File

@ -11,11 +11,12 @@
phx-submit="save"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
<%= if @changeset.action && not @changeset.valid? do %>
<div class="invalid-feedback col-span-3 text-center">
<%= changeset_errors(@changeset) %>
</div>
<% end %>
<div
:if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %>
@ -23,17 +24,19 @@
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :desc,
id: "ammo-type-form-desc",
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") %>
<a
href="https://en.wikipedia.org/wiki/Bullet#Abbreviations"
<.link
href="https://shootersreference.com/reloadingdata/bullet_abbreviations/"
class="col-span-3 text-center link title text-md text-primary-600"
>
<%= gettext("Example bullet type abbreviations") %>
</a>
</.link>
<%= 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",
@ -51,14 +54,14 @@
<%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :cartridge,
class: "text-center col-span-2 input input-primary",
placeholder: "5.56x46mm NATO"
placeholder: gettext("5.56x46mm NATO")
) %>
<%= error_tag(f, :cartridge, "col-span-3 text-center") %>
<%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :caliber,
class: "text-center col-span-2 input input-primary",
placeholder: ".223"
placeholder: gettext(".223")
) %>
<%= error_tag(f, :caliber, "col-span-3 text-center") %>
@ -111,21 +114,21 @@
<%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :pressure,
class: "text-center col-span-2 input input-primary",
placeholder: "+P"
placeholder: gettext("+P")
) %>
<%= error_tag(f, :pressure, "col-span-3 text-center") %>
<%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :primer_type,
class: "text-center col-span-2 input input-primary",
placeholder: "Boxer"
placeholder: gettext("Boxer")
) %>
<%= error_tag(f, :primer_type, "col-span-3 text-center") %>
<%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :firing_type,
class: "text-center col-span-2 input input-primary",
placeholder: "Centerfire"
placeholder: gettext("Centerfire")
) %>
<%= error_tag(f, :firing_type, "col-span-3 text-center") %>

View File

@ -69,25 +69,21 @@ defmodule CanneryWeb.AmmoTypeLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
%{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user)
prompt = dgettext("prompts", "%{name} deleted succesfully", name: name)
{:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()}
end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()}
end
@impl true
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))}
end
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
{:noreply,
socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :search, search_term))}
search_path = Routes.ammo_type_index_path(Endpoint, :search, search_term)
{:noreply, socket |> push_patch(to: search_path)}
end
defp list_ammo_types(%{assigns: %{search: search, current_user: current_user}} = socket) do

View File

@ -20,15 +20,16 @@
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl">
<.form
:let={f}
for={:search}
for={%{}}
as={:search}
phx-change="search"
phx-submit="search"
class="grow self-stretch flex flex-col items-stretch"
data-qa="ammo_type_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search catalog")
) %>
@ -60,7 +61,9 @@
<.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)}
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>
</.link>
@ -68,7 +71,9 @@
<.link
patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)}
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>
</.link>
@ -76,7 +81,9 @@
<.link
patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)}
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>
</.link>
@ -93,7 +100,9 @@
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>
</.link>
@ -104,17 +113,18 @@
<% end %>
</div>
<%= if @live_action in [:new, :edit, :clone] do %>
<.modal return_to={Routes.ammo_type_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id || :new}
title={@page_title}
action={@live_action}
ammo_type={@ammo_type}
return_to={Routes.ammo_type_index_path(Endpoint, :index)}
current_user={@current_user}
}
/>
</.modal>
<% end %>
<.modal
:if={@live_action in [:new, :edit, :clone]}
return_to={Routes.ammo_type_index_path(Endpoint, :index)}
>
<.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id || :new}
title={@page_title}
action={@live_action}
ammo_type={@ammo_type}
return_to={Routes.ammo_type_index_path(Endpoint, :index)}
current_user={@current_user}
}
/>
</.modal>

View File

@ -4,8 +4,7 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
"""
use CanneryWeb, :live_view
import CanneryWeb.Components.AmmoGroupCard
alias Cannery.{Ammo, Ammo.AmmoType}
alias Cannery.{ActivityLog, Ammo, Ammo.AmmoType}
alias CanneryWeb.Endpoint
@fields_list [
@ -58,12 +57,10 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
{:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)}
end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()}
end
@impl true
def handle_event(
"toggle_table",
_params,
@ -94,12 +91,42 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
ammo_type |> Map.get(field) != default_value
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
socket
|> assign(
page_title: page_title(live_action, ammo_type),
ammo_type: ammo_type,
ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used),
avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user),
ammo_groups: ammo_groups,
cprs: ammo_groups |> Ammo.get_cprs(current_user),
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_list: @fields_list,
fields_to_display: fields_to_display
)
@ -113,6 +140,9 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
socket |> display_ammo_type(ammo_type)
end
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
defp page_title(action, %{name: ammo_type_name}) when action in [:show, :table],
do: ammo_type_name

View File

@ -3,19 +3,20 @@
<%= @ammo_type.name %>
</h1>
<%= if @ammo_type.desc do %>
<span class="max-w-2xl w-full px-8 py-4 rounded-lg
<span
:if={@ammo_type.desc}
class="max-w-2xl w-full px-8 py-4 rounded-lg
text-center title text-lg
border border-primary-600">
<%= @ammo_type.desc %>
</span>
<% end %>
border border-primary-600"
>
<%= @ammo_type.desc %>
</span>
<div class="flex space-x-4 justify-center items-center text-primary-600">
<.link
patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)}
class="text-primary-600 link"
data-qa="edit"
aria-label={dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: @ammo_type.name)}
>
<i class="fa-fw fa-lg fas fa-edit"></i>
</.link>
@ -31,7 +32,9 @@
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>
</.link>
@ -68,63 +71,59 @@
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_round_count_for_ammo_type(@current_user) %>
<%= @rounds %>
</span>
<h3 class="title text-lg">
<%= gettext("Used rounds:") %>
</h3>
<%= if @show_used do %>
<h3 class="title text-lg">
<%= gettext("Used rounds:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_used_count_for_ammo_type(@current_user) %>
</span>
<span class="text-primary-600">
<%= @used_rounds %>
</span>
<h3 class="title text-lg">
<%= gettext("Total ever rounds:") %>
</h3>
<h3 class="title text-lg">
<%= gettext("Total ever rounds:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_historical_count_for_ammo_type(@current_user) %>
</span>
</div>
<span class="text-primary-600">
<%= @historical_round_count %>
</span>
<% end %>
<hr class="hr" />
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center">
<h3 class="title text-lg">
<%= gettext("Packs:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user) %>
<%= @packs_count %>
</span>
<h3 class="title text-lg">
<%= gettext("Used packs:") %>
</h3>
<%= if @show_used do %>
<h3 class="title text-lg">
<%= gettext("Used packs:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_used_ammo_groups_count_for_type(@current_user) %>
</span>
<span class="text-primary-600">
<%= @used_packs_count %>
</span>
<h3 class="title text-lg">
<%= gettext("Total ever packs:") %>
</h3>
<h3 class="title text-lg">
<%= gettext("Total ever packs:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user, true) %>
</span>
</div>
<span class="text-primary-600">
<%= @historical_packs_count %>
</span>
<% end %>
<hr class="hr" />
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center">
<h3 class="title text-lg">
<%= gettext("Added on:") %>
</h3>
<span class="text-primary-600">
<%= @ammo_type.inserted_at |> display_datetime() %>
<.datetime id={"#{@ammo_type.id}-inserted-at"} datetime={@ammo_type.inserted_at} />
</span>
<%= if @avg_cost_per_round do %>
@ -133,9 +132,7 @@
</h3>
<span class="text-primary-600">
<%= gettext("$%{amount}",
amount: @avg_cost_per_round |> :erlang.float_to_binary(decimals: 2)
) %>
<%= gettext("$%{amount}", amount: display_currency(@avg_cost_per_round)) %>
</span>
<% else %>
<h3 class="mx-8 my-4 title text-lg text-primary-600 col-span-2">
@ -174,7 +171,7 @@
ammo_groups={@ammo_groups}
current_user={@current_user}
>
<:container :let={%{container: %{name: container_name} = container}}>
<:container :let={{_ammo_group, %{name: container_name} = container}}>
<.link
navigate={Routes.container_show_path(Endpoint, :show, container)}
class="mx-2 my-1 link"
@ -185,25 +182,32 @@
</.live_component>
<% else %>
<div class="flex flex-wrap justify-center items-stretch">
<%= for ammo_group <- @ammo_groups do %>
<.ammo_group_card ammo_group={ammo_group} show_container={true} />
<% end %>
<.ammo_group_card
:for={%{id: ammo_group_id} = ammo_group <- @ammo_groups}
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}
show_container={true}
/>
</div>
<% end %>
<% end %>
</div>
</div>
<%= if @live_action in [:edit] do %>
<.modal return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}>
<.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id}
title={@page_title}
action={@live_action}
ammo_type={@ammo_type}
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
current_user={@current_user}
/>
</.modal>
<% end %>
<.modal
:if={@live_action == :edit}
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
>
<.live_component
module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@ammo_type.id}
title={@page_title}
action={@live_action}
ammo_type={@ammo_type}
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
current_user={@current_user}
/>
</.modal>

View File

@ -4,25 +4,41 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do
"""
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
@impl true
@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()
) :: {:ok, Socket.t()}
def update(%{container: container, current_user: current_user} = assigns, socket) do
tags = Tags.list_tags(current_user)
container = container |> Repo.preload(:tags)
{:ok, socket |> assign(assigns) |> assign(tags: tags, container: container)}
def update(
%{container: _container, current_path: _current_path, current_user: current_user} =
assigns,
socket
) do
tags = Containers.list_tags(current_user)
{:ok, socket |> assign(assigns) |> assign(:tags, tags)}
end
@impl true
def handle_event(
"save",
%{"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
socket =
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 ->
_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)
socket |> put_flash(:info, prompt) |> assign(container: container)
socket |> put_flash(:info, prompt) |> push_patch(to: current_path)
end
{:noreply, socket}
end
@impl true
def handle_event(
"delete",
%{"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
socket =
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 ->
_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)
socket |> put_flash(:info, prompt) |> assign(container: container)
socket |> put_flash(:info, prompt) |> push_patch(to: current_path)
end
{:noreply, socket}

View File

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

View File

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

View File

@ -11,11 +11,12 @@
phx-change="validate"
phx-submit="save"
>
<%= if @changeset.action && not @changeset.valid? do %>
<div class="invalid-feedback col-span-3 text-center">
<%= changeset_errors(@changeset) %>
</div>
<% end %>
<div
:if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :name,
@ -26,9 +27,11 @@
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :desc,
id: "container-form-desc",
class: "input input-primary col-span-2",
placeholder: gettext("Metal ammo can with the anime girl sticker"),
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") %>
@ -41,9 +44,11 @@
<%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :location,
id: "container-form-location",
class: "input input-primary col-span-2",
placeholder: gettext("On the bookshelf"),
phx_hook: "MaintainAttrs",
placeholder: gettext("On the bookshelf")
phx_update: "ignore"
) %>
<%= error_tag(f, :location, "col-span-3 text-center") %>

View File

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

View File

@ -20,15 +20,16 @@
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl">
<.form
:let={f}
for={:search}
for={%{}}
as={:search}
phx-change="search"
phx-submit="search"
class="grow self-stretch flex flex-col items-stretch"
data-qa="container_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search containers")
) %>
@ -61,6 +62,9 @@
<.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>
@ -70,7 +74,9 @@
<.link
patch={Routes.container_index_path(Endpoint, :edit, container)}
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>
@ -78,7 +84,9 @@
<.link
patch={Routes.container_index_path(Endpoint, :clone, container)}
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>
</.link>
@ -91,7 +99,9 @@
data-confirm={
dgettext("prompts", "Are you sure you want to delete %{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>
@ -99,78 +109,88 @@
</.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>
</.link>
<.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"
data-qa={"clone-#{container.id}"}
>
<i class="fa-fw fa-lg fas fa-copy"></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
)
}
data-qa={"delete-#{container.id}"}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</.container_card>
<% end %>
<.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>
</.link>
</.container_card>
</div>
<% end %>
<% end %>
</div>
<%= if @live_action in [:new, :edit, :clone] do %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id || :new}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_index_path(Endpoint, :index)}
current_user={@current_user}
/>
</.modal>
<% end %>
<%= if @live_action == :edit_tags do %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
current_user={@current_user}
/>
</.modal>
<%= case @live_action do %>
<% modifying when modifying in [:new, :edit, :clone] -> %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id || :new}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_index_path(Endpoint, :index)}
current_user={@current_user}
/>
</.modal>
<% :edit_tags -> %>
<.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)}
current_user={@current_user}
/>
</.modal>
<% _ -> %>
<% end %>

View File

@ -4,8 +4,7 @@ defmodule CanneryWeb.ContainerLive.Show do
"""
use CanneryWeb, :live_view
import CanneryWeb.Components.{AmmoGroupCard, TagCard}
alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container, Repo, Tags}
alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Ecto.Changeset
alias Phoenix.LiveView.Socket
@ -31,7 +30,7 @@ defmodule CanneryWeb.ContainerLive.Show do
%{assigns: %{container: container, current_user: current_user}} = socket
) do
socket =
case Tags.get_tag(tag_id, current_user) do
case Containers.get_tag(tag_id, current_user) do
{:ok, tag} ->
_count = Containers.remove_tag!(container, tag, current_user)
@ -43,14 +42,13 @@ defmodule CanneryWeb.ContainerLive.Show do
socket |> put_flash(:info, prompt) |> render_container()
{:error, error_string} ->
socket |> put_flash(:error, error_string)
{:error, :not_found} ->
socket |> put_flash(:error, dgettext("errors", "Tag not found"))
end
{:noreply, socket}
end
@impl true
def handle_event(
"delete_container",
_params,
@ -84,12 +82,10 @@ defmodule CanneryWeb.ContainerLive.Show do
{:noreply, socket}
end
@impl true
def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do
{:noreply, socket |> assign(:show_used, !show_used) |> render_container()}
end
@impl true
def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table) |> render_container()}
end
@ -100,12 +96,11 @@ defmodule CanneryWeb.ContainerLive.Show do
id,
current_user
) do
%{name: container_name} =
container =
Containers.get_container!(id, current_user)
|> Repo.preload([:tags], force: true)
%{name: container_name} = container = Containers.get_container!(id, current_user)
ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used)
original_counts = ammo_groups |> Ammo.get_original_counts(current_user)
cprs = ammo_groups |> Ammo.get_cprs(current_user)
last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user)
page_title =
case live_action do
@ -114,7 +109,16 @@ defmodule CanneryWeb.ContainerLive.Show do
:edit_tags -> gettext("Edit %{name} tags", name: container_name)
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: ammo_groups,
original_counts: original_counts,
cprs: cprs,
last_used_dates: last_used_dates,
page_title: page_title
)
end
@spec render_container(Socket.t()) :: Socket.t()

View File

@ -3,42 +3,35 @@
<%= @container.name %>
</h1>
<%= if @container.desc do %>
<span class="rounded-lg title text-lg">
<%= gettext("Description:") %>
<%= @container.desc %>
</span>
<% end %>
<span :if={@container.desc} class="rounded-lg title text-lg">
<%= gettext("Description:") %>
<%= @container.desc %>
</span>
<span class="rounded-lg title text-lg">
<%= gettext("Type:") %>
<%= @container.type %>
</span>
<%= if @container.location do %>
<span class="rounded-lg title text-lg">
<%= gettext("Location:") %>
<%= @container.location %>
</span>
<% end %>
<span :if={@container.location} class="rounded-lg title text-lg">
<%= gettext("Location:") %>
<%= @container.location %>
</span>
<%= unless @ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg">
<%= if @show_used do %>
<%= gettext("Total packs:") %>
<% else %>
<%= gettext("Packs:") %>
<% end %>
<%= gettext("Packs:") %>
<%= @ammo_groups |> Enum.reject(fn %{count: count} -> count in [0, nil] end) |> Enum.count() %>
</span>
<span :if={@show_used} class="rounded-lg title text-lg">
<%= gettext("Total packs:") %>
<%= Enum.count(@ammo_groups) %>
</span>
<span class="rounded-lg title text-lg">
<%= if @show_used do %>
<%= gettext("Total rounds:") %>
<% else %>
<%= gettext("Rounds:") %>
<% end %>
<%= @container |> Containers.get_container_rounds!() %>
<%= gettext("Rounds:") %>
<%= @round_count %>
</span>
<% end %>
@ -46,7 +39,7 @@
<.link
patch={Routes.container_show_path(Endpoint, :edit, @container)}
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>
</.link>
@ -58,7 +51,9 @@
data-confirm={
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>
</.link>
@ -82,9 +77,7 @@
</div>
<% else %>
<div class="flex flex-wrap justify-center items-center">
<%= for tag <- @container.tags do %>
<.simple_tag_card tag={tag} />
<% end %>
<.simple_tag_card :for={tag <- @container.tags} tag={tag} />
<div class="mx-4 my-2">
<.link
@ -134,39 +127,45 @@
</.live_component>
<% else %>
<div class="flex flex-wrap justify-center items-stretch">
<%= for ammo_group <- @ammo_groups do %>
<.ammo_group_card ammo_group={ammo_group} />
<% end %>
<.ammo_group_card
:for={%{id: ammo_group_id} = ammo_group <- @ammo_groups}
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>
<% end %>
<% end %>
</div>
</div>
<%= if @live_action in [:edit] do %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component
module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_user={@current_user}
/>
</.modal>
<% end %>
<%= if @live_action == :edit_tags do %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_user={@current_user}
/>
</.modal>
<%= case @live_action do %>
<% :edit -> %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component
module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_user={@current_user}
/>
</.modal>
<% :edit_tags -> %>
<.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id}
title={@page_title}
action={@live_action}
container={@container}
return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)}
current_user={@current_user}
/>
</.modal>
<% _ -> %>
<% end %>

View File

@ -12,165 +12,6 @@ defmodule CanneryWeb.HomeLive do
@impl true
def mount(_params, _session, socket) do
admins = Accounts.list_users_by_role(:admin)
socket = 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>
"""
{:ok, socket |> assign(page_title: gettext("Home"), admins: admins, version: @version)}
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
@moduledoc """
Livecomponent that can update or create an Cannery.Invites.Invite
Livecomponent that can update or create an Cannery.Accounts.Invite
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Invites, Invites.Invite}
alias Ecto.Changeset
alias Cannery.Accounts.{Invite, Invites, User}
alias Phoenix.LiveView.Socket
@impl true

View File

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

View File

@ -1,26 +1,16 @@
defmodule CanneryWeb.InviteLive.Index do
@moduledoc """
Liveview to show a Cannery.Invites.Invite index
Liveview to show a Cannery.Accounts.Invite index
"""
use CanneryWeb, :live_view
import CanneryWeb.Components.{InviteCard, UserCard}
alias Cannery.{Accounts, Invites, Invites.Invite}
alias CanneryWeb.{Endpoint, HomeLive}
alias Cannery.Accounts
alias Cannery.Accounts.{Invite, Invites}
alias Phoenix.LiveView.JS
@impl true
def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
socket =
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}
def mount(_params, _session, socket) do
{:ok, socket |> display_invites()}
end
@impl true
@ -29,8 +19,8 @@ defmodule CanneryWeb.InviteLive.Index do
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket
|> assign(page_title: gettext("Edit Invite"), invite: Invites.get_invite!(id, current_user))
invite = Invites.get_invite!(id, current_user)
socket |> assign(page_title: gettext("Edit Invite"), invite: invite)
end
defp apply_action(socket, :new, _params) do
@ -50,7 +40,7 @@ defmodule CanneryWeb.InviteLive.Index do
%{name: invite_name} =
id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user)
prompt = dgettext("prompts", "%{name} deleted succesfully", name: invite_name)
prompt = dgettext("prompts", "%{invite_name} deleted succesfully", invite_name: invite_name)
{:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
end
@ -61,10 +51,12 @@ defmodule CanneryWeb.InviteLive.Index do
) do
socket =
Invites.get_invite!(id, current_user)
|> Invites.update_invite(%{"uses_left" => nil}, current_user)
|> Invites.update_invite(%{uses_left: nil}, current_user)
|> case do
{:ok, %{name: invite_name}} ->
prompt = dgettext("prompts", "%{name} updated succesfully", name: invite_name)
prompt =
dgettext("prompts", "%{invite_name} updated succesfully", invite_name: invite_name)
socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} ->
@ -81,10 +73,12 @@ defmodule CanneryWeb.InviteLive.Index do
) do
socket =
Invites.get_invite!(id, current_user)
|> Invites.update_invite(%{"uses_left" => nil, "disabled_at" => nil}, current_user)
|> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user)
|> case do
{:ok, %{name: invite_name}} ->
prompt = dgettext("prompts", "%{name} enabled succesfully", name: invite_name)
prompt =
dgettext("prompts", "%{invite_name} enabled succesfully", invite_name: invite_name)
socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} ->
@ -103,10 +97,12 @@ defmodule CanneryWeb.InviteLive.Index do
socket =
Invites.get_invite!(id, current_user)
|> Invites.update_invite(%{"uses_left" => 0, "disabled_at" => now}, current_user)
|> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user)
|> case do
{:ok, %{name: invite_name}} ->
prompt = dgettext("prompts", "%{name} disabled succesfully", name: invite_name)
prompt =
dgettext("prompts", "%{invite_name} disabled succesfully", invite_name: invite_name)
socket |> put_flash(:info, prompt) |> display_invites()
{:error, changeset} ->
@ -116,22 +112,17 @@ defmodule CanneryWeb.InviteLive.Index do
{:noreply, socket}
end
@impl true
def handle_event("copy_to_clipboard", _params, socket) do
prompt = dgettext("prompts", "Copied to clipboard")
{:noreply, socket |> put_flash(:info, prompt)}
{:noreply, socket |> put_flash(:info, dgettext("prompts", "Copied to clipboard"))}
end
@impl true
def handle_event(
"delete_user",
%{"id" => id},
%{assigns: %{current_user: current_user}} = socket
) do
%{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user)
prompt = dgettext("prompts", "%{name} deleted succesfully", name: user_email)
prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email)
{:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
end
@ -144,7 +135,8 @@ defmodule CanneryWeb.InviteLive.Index do
|> Map.get(:admin, [])
|> 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, [])
socket |> assign(invites: invites, admins: admins, users: users)
socket |> assign(invites: invites, use_counts: use_counts, admins: admins, users: users)
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">
<%= gettext("Invites") %>
</h1>
@ -18,70 +18,78 @@
</.link>
<% end %>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<%= for invite <- @invites do %>
<.invite_card invite={invite}>
<:code_actions>
<form phx-submit="copy_to_clipboard">
<button
type="submit"
class="mx-2 my-1 btn btn-primary"
phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")}
>
<%= dgettext("actions", "Copy to clipboard") %>
</button>
</form>
</:code_actions>
<.link
patch={Routes.invite_index_path(Endpoint, :edit, invite)}
class="text-primary-600 link"
data-qa={"edit-#{invite.id}"}
>
<i class="fa-fw fa-lg fas fa-edit"></i>
</.link>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_invite"
phx-value-id={invite.id}
data-confirm={
dgettext("prompts", "Are you sure you want to delete the invite for %{name}?",
name: invite.name
)
}
data-qa={"delete-#{invite.id}"}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
<%= if invite.disabled_at |> is_nil() do %>
<a href="#" class="btn btn-primary" phx-click="disable_invite" phx-value-id={invite.id}>
<%= dgettext("actions", "Disable") %>
</a>
<% else %>
<a href="#" class="btn btn-primary" phx-click="enable_invite" phx-value-id={invite.id}>
<%= dgettext("actions", "Enable") %>
</a>
<% end %>
<%= if invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil()) do %>
<a
href="#"
class="btn btn-primary"
phx-click="set_unlimited"
phx-value-id={invite.id}
data-confirm={
dgettext("prompts", "Are you sure you want to make %{name} unlimited?",
name: invite.name
)
<div class="flex flex-col justify-center items-stretch space-y-4">
<.invite_card
:for={invite <- @invites}
invite={invite}
current_user={@current_user}
use_count={Map.get(@use_counts, invite.id)}
>
<:code_actions>
<form phx-submit="copy_to_clipboard">
<button
type="submit"
class="mx-2 my-1 btn btn-primary"
phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")}
aria-label={
dgettext("actions", "Copy invite link for %{invite_name}", invite_name: invite.name)
}
>
<%= dgettext("actions", "Set Unlimited") %>
</a>
<% end %>
</.invite_card>
<% end %>
<%= dgettext("actions", "Copy to clipboard") %>
</button>
</form>
</:code_actions>
<.link
patch={Routes.invite_index_path(Endpoint, :edit, invite)}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name)
}
>
<i class="fa-fw fa-lg fas fa-edit"></i>
</.link>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_invite"
phx-value-id={invite.id}
data-confirm={
dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?",
invite_name: invite.name
)
}
aria-label={
dgettext("actions", "Delete invite for %{invite_name}", invite_name: invite.name)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
<.link
href="#"
class="btn btn-primary"
phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"}
phx-value-id={invite.id}
>
<%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %>
</.link>
<.link
:if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())}
href="#"
class="btn btn-primary"
phx-click="set_unlimited"
phx-value-id={invite.id}
data-confirm={
dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?",
invite_name: invite.name
)
}
>
<%= dgettext("actions", "Set Unlimited") %>
</.link>
</.invite_card>
</div>
<%= unless @admins |> Enum.empty?() do %>
@ -91,26 +99,24 @@
<%= gettext("Admins") %>
</h1>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<%= for admin <- @admins do %>
<.user_card user={admin}>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_user"
phx-value-id={admin.id}
data-confirm={
dgettext(
"prompts",
"Are you sure you want to delete %{email}? This action is permanent!",
email: admin.email
)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</.user_card>
<% end %>
<div class="flex flex-col justify-center items-stretch space-y-4">
<.user_card :for={admin <- @admins} user={admin}>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_user"
phx-value-id={admin.id}
data-confirm={
dgettext(
"prompts",
"Are you sure you want to delete %{email}? This action is permanent!",
email: admin.email
)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</.user_card>
</div>
<% end %>
@ -121,40 +127,36 @@
<%= gettext("Users") %>
</h1>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<%= for user <- @users do %>
<.user_card user={user}>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_user"
phx-value-id={user.id}
data-confirm={
dgettext(
"prompts",
"Are you sure you want to delete %{email}? This action is permanent!",
email: user.email
)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</.user_card>
<% end %>
<div class="flex flex-col justify-center items-stretch space-y-4">
<.user_card :for={user <- @users} user={user}>
<.link
href="#"
class="text-primary-600 link"
phx-click="delete_user"
phx-value-id={user.id}
data-confirm={
dgettext(
"prompts",
"Are you sure you want to delete %{email}? This action is permanent!",
email: user.email
)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</.user_card>
</div>
<% end %>
</div>
<%= if @live_action in [:new, :edit] do %>
<.modal return_to={Routes.invite_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.InviteLive.FormComponent}
id={@invite.id || :new}
title={@page_title}
action={@live_action}
invite={@invite}
return_to={Routes.invite_index_path(Endpoint, :index)}
current_user={@current_user}
/>
</.modal>
<% end %>
<.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}>
<.live_component
module={CanneryWeb.InviteLive.FormComponent}
id={@invite.id || :new}
title={@page_title}
action={@live_action}
invite={@invite}
return_to={Routes.invite_index_path(Endpoint, :index)}
current_user={@current_user}
/>
</.modal>

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