Compare commits

...

45 Commits
0.9.9 ... dev

Author SHA1 Message Date
859e5756ae add new language note to contributing guide
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-13 19:09:01 +00:00
45b46b761d update version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 04:20:07 +00:00
bda051ebc8 fix cannery logo on home page
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 04:12:37 +00:00
01fa306429 eliminate possible style conflicts
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-05 04:05:10 +00:00
5cff5d8280 update deps again
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 03:04:45 +00:00
71778d12a6 shorten config 2025-04-05 02:54:34 +00:00
9b721a170b fix migrations
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-05 02:46:59 +00:00
366a6d160d format migration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 01:20:35 +00:00
926d2fe6c2 fix cast_datetime
Some checks are pending
continuous-integration/drone/push Build is running
2025-04-05 01:19:20 +00:00
c7bd7238c6 improve accuracy of timestamps
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 01:13:00 +00:00
e2c17b6b51 fix drone
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 00:33:09 +00:00
20988ac1ec fix dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2025-04-05 00:31:35 +00:00
37d101a71e update deps
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2025-04-05 00:13:01 +00:00
449a92e4b7 remove npm engine requirement
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-15 02:30:28 +00:00
5d17ee0a11 fix broken install step
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-13 22:02:18 +00:00
b6b6cecc0a update deps
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-02-01 02:12:41 -05:00
08916a504f add button to resend email verification email 2025-02-01 02:12:41 -05:00
3eda522903 move staging to container 2025-02-01 02:12:41 -05:00
2e6e26006d fix style issues 2025-02-01 02:12:41 -05:00
b66d0ea8a1 add date range to range page 2025-02-01 02:12:41 -05:00
839e1d7124 fix dates not displaying properly 2025-01-31 22:33:07 -05:00
76834845a3 update deps 2025-01-12 16:51:27 -05:00
cc1413ade5 finish updating deps
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-10-26 17:16:04 -04:00
668e4c611b update gettext schema and use macros for cannery app
Some checks failed
continuous-integration/drone/push Build is failing
2024-10-26 17:07:32 -04:00
ab3d3721d6 allow filtering ammo types when creating new packs and fix some form errors not displaying on create 2024-10-26 17:07:32 -04:00
7e14f292a6 add slimselect to select elements with user content 2024-10-26 16:32:47 -04:00
16a5cb9254 fix registration page not offering all translations 2024-10-26 14:20:23 -04:00
f722f9901b update deps 2024-10-26 13:12:34 -04:00
6adae82e94 build armv7
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-16 17:50:43 -04:00
a87bf15f72 build arm
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-16 17:01:35 -04:00
shibao
75c0f8642b add project website to readme
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-15 09:44:34 -04:00
ec782515ac improve testing db timeout
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-28 13:49:26 -04:00
e1cb46cb97 fix dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-07-28 13:35:58 -04:00
56a49ed2e3 fix changesets
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2024-07-28 13:17:33 -04:00
f25c151956 add debounces to more fields 2024-07-28 13:05:50 -04:00
c2ddc2ae0d update deps 2024-07-28 12:21:36 -04:00
33e4d26a3d fix emails 2024-07-28 12:21:36 -04:00
179d67a896 update versions 2024-07-28 12:21:35 -04:00
15354d6004 update versions
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-18 09:14:53 -04:00
e358cd6e4e downgrade versions until hex is supported in build
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-06-16 12:13:34 -04:00
202b70dc66 fix changeset warning
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is failing
2024-06-16 11:48:49 -04:00
b963baa49d fix an issue with emails not being able to be sent 2024-06-16 11:46:35 -04:00
70701a27d3 fix issue with oban exception logger 2024-06-16 11:42:00 -04:00
67dc16d222 update deps 2024-06-16 11:42:00 -04:00
fa35038426 update to elixir 1.17 2024-06-16 11:42:00 -04:00
179 changed files with 5328 additions and 16833 deletions

View File

@ -17,7 +17,7 @@ steps:
- .mix - .mix
- name: test - name: test
image: elixir:1.16.1-alpine image: elixir:1.18.3-otp-27-alpine
environment: environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld HOST: testing.example.tld
@ -26,17 +26,16 @@ steps:
MIX_ENV: test MIX_ENV: test
commands: commands:
- apk add --no-cache build-base npm git - apk add --no-cache build-base npm git
- mix local.rebar --force --if-missing - mix local.rebar --force
- mix local.hex --force --if-missing - mix local.hex --force
- mix deps.get - mix deps.get
- npm set cache .npm - npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline - npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy - mix do phx.digest, gettext.extract, assets.deploy
- mix do phx.digest, gettext.extract
- mix test.all - mix test.all
- name: build and publish stable - name: build and publish stable
image: thegeeklab/drone-docker-buildx image: plugins/docker
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
@ -44,6 +43,8 @@ steps:
compress: true compress: true
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
@ -54,7 +55,7 @@ steps:
- stable - stable
- name: build and publish tagged version - name: build and publish tagged version
image: thegeeklab/drone-docker-buildx image: plugins/docker
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
@ -62,6 +63,8 @@ steps:
compress: true compress: true
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:

9
.gitignore vendored
View File

@ -28,10 +28,11 @@ npm-debug.log
# The directory NPM downloads your dependencies sources to. # The directory NPM downloads your dependencies sources to.
/assets/node_modules/ /assets/node_modules/
# Since we are building assets from assets/, # Ignore assets that are produced by build tools.
# we ignore priv/static. You may want to comment /priv/static/assets/
# this depending on your deployment strategy.
/priv/static/ # Ignore digested assets cache.
/priv/static/cache_manifest.json
# vs code # vs code
.elixir_ls/ .elixir_ls/

View File

@ -1,3 +1,3 @@
elixir 1.16.1-otp-26 elixir 1.18.3-otp-27
erlang 26.2.2 erlang 27.3.1
nodejs 21.6.2 nodejs 23.10.0

View File

@ -1,3 +1,32 @@
# v0.9.14
- Update deps
- Fix wrapping issues with search bars
- Improve accuracy of timestamps
# v0.9.13
- Add button to resend email verification email
- Move staging to container, rather than ammo
- Add date restriction dropdown to range page
- Fix dates not rendering properly in table
- Update deps
# v0.9.12
- Allow filtering ammo types when creating new packs
- Add SlimSelect to select elements with user content
- Fix registration page not offering all translations
- Update deps
# v0.9.11
- Fix an issue with emails not being able to be sent for real this time
- Fix some dropdowns not filling in the correct data
- Add debounces to more fields
- Update deps
# v0.9.10
- Fix issue with logger failing on oban exceptions
- Fix an issue with emails not being able to be sent
- Update deps
# v0.9.9 # v0.9.9
- Actually fix bar graph - Actually fix bar graph

View File

@ -10,6 +10,9 @@ status](https://weblate.bubbletea.dev/widgets/cannery/-/287x66-black.png)](https
If you're multilingual, this project can use your translations! Visit If you're multilingual, this project can use your translations! Visit
[weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information. [weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information.
Also, if your language isn't displayed here, I'd love to add that language so
you can start! Please contact me at
(shibao@bubbletea.dev)[mailto:shibao@bubbletea.dev] and let me know!
## Style Tips ## Style Tips
@ -127,7 +130,7 @@ In `test` mode (or in the Docker container), Cannery will listen for the same en
In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime: In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime:
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
- `SMTP_HOST`: The url for your SMTP email provider. Must be set - `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! - `SMTP_USERNAME`: The username for your SMTP relay. Must be set!

View File

@ -1,4 +1,4 @@
FROM elixir:1.16.1-alpine AS build FROM elixir:1.18.3-otp-27-alpine AS build
# install build dependencies # install build dependencies
RUN apk add --no-cache build-base npm git python3 RUN apk add --no-cache build-base npm git python3
@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.hex --force && \ RUN mix local.rebar --force && \
mix local.rebar --force mix local.hex --force
# set build ENV # set build ENV
ENV MIX_ENV=prod ENV MIX_ENV=prod
@ -25,13 +25,12 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
COPY lib lib COPY lib lib
COPY priv priv COPY priv priv
COPY assets assets COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix do phx.digest, gettext.extract RUN mix do phx.digest, gettext.extract
# compile and build release # compile and build release
# uncomment COPY if rel/ exists # uncomment COPY if rel/ exists
# COPY rel rel # COPY rel rel
RUN mix do compile, release RUN mix do assets.deploy, compile, release
# prepare release image # prepare release image
FROM alpine:latest AS app FROM alpine:latest AS app
@ -43,6 +42,8 @@ WORKDIR /app
RUN chown nobody:nobody /app RUN chown nobody:nobody /app
ENV MIX_ENV=prod
USER nobody:nobody USER nobody:nobody
COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./ COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./

View File

@ -60,7 +60,7 @@ You can use the following environment variables to configure Cannery in
Defaults to `false`. Defaults to `false`.
- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`.
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to - `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`. public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US` - `LOCALE`: Sets a custom default locale. Defaults to `en_US`
@ -94,6 +94,7 @@ license can be found at
# Links # Links
- [Website](https://cannery.app): Project website
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please - [Github](https://github.com/shibaobun/cannery): Source code mirror, please

View File

@ -1,5 +0,0 @@
{
"presets": [
"@babel/preset-env"
]
}

View File

@ -1,154 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
$fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "components";
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
// disconnect toast
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}

View File

@ -1,57 +0,0 @@
@layer components {
.input {
@apply rounded-lg px-4 py-2 border focus:outline-none;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
@apply text-black;
}
.checkbox {
-ms-transform: scale(1.5);
-moz-transform: scale(1.5);
-webkit-transform: scale(1.5);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply leading-5 tracking-wide;
}
.btn {
@apply focus:outline-none px-4 py-2 rounded-lg;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600;
@apply border-primary-500 focus:border-primary-600 active:border-primary-600;
@apply text-white;
}
.btn-alert {
@apply bg-red-600 focus:bg-red-700 active:bg-red-800;
@apply border-red-600 focus:border-red-700 active:border-red-800;
@apply text-white;
}
.hr {
@apply border border-primary-300 w-full max-w-2xl;
}
.hr-light {
@apply border border-white w-full max-w-2xl;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
}

266
assets/css/style.css Normal file
View File

@ -0,0 +1,266 @@
@import "tailwindcss" source("../..");
@theme {
--color-primary-50: oklch(0.985 0.002 247.839);
--color-primary-100: oklch(0.967 0.003 264.542);
--color-primary-200: oklch(0.928 0.006 264.531);
--color-primary-300: oklch(0.872 0.01 258.338);
--color-primary-400: oklch(0.707 0.022 261.325);
--color-primary-500: oklch(0.551 0.027 264.364);
--color-primary-600: oklch(0.446 0.03 256.802);
--color-primary-700: oklch(0.373 0.034 259.733);
--color-primary-800: oklch(0.278 0.033 256.848);
--color-primary-900: oklch(0.21 0.034 264.665);
--color-primary-950: oklch(0.13 0.028 261.692);
}
@import "@fortawesome/fontawesome-free/css/fontawesome" source("../..");
@import "@fortawesome/fontawesome-free/css/regular" source("../..");
@import "@fortawesome/fontawesome-free/css/solid" source("../..");
@import "@fortawesome/fontawesome-free/css/brands" source("../..");
@import "slim-select/styles" source("../..");
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
/* disconnect toast */
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}
/* components */
.input, .ss-main, .ss-content, .ss-search input[type="search"] {
@apply px-4 py-2 rounded-lg border focus:outline-hidden;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
@apply text-black;
}
.checkbox {
-ms-transform: scale(1.5);
-moz-transform: scale(1.5);
-webkit-transform: scale(1.5);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply tracking-wide leading-5;
}
.btn {
@apply px-4 py-2 rounded-lg focus:outline-hidden;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600;
@apply border-primary-500 focus:border-primary-600 active:border-primary-600;
@apply text-white;
}
.btn-alert {
@apply bg-rose-600 focus:bg-rose-700 active:bg-rose-800;
@apply border-rose-600 focus:border-rose-700 active:border-rose-800;
@apply text-white;
}
.hr {
@apply w-full max-w-2xl border border-primary-300;
}
.hr-light {
@apply w-full max-w-2xl border border-white;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
/* slim select */
.ss-main.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.ss-open-above {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.ss-content.ss-open-below {
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.ss-content.input-primary .ss-search input[type="search"] {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content.ss-open-above .ss-search {
padding: var(--ss-spacing-l) 0 0 0;
}
.ss-content.ss-open-below .ss-search {
padding: 0 0 var(--ss-spacing-l) 0;
}
.ss-content.ss-open-above .ss-list > *:not(:first-child) {
margin: var(--ss-spacing-l) 0 0 0;
}
.ss-content.ss-open-below .ss-list > *:not(:last-child) {
margin: 0 0 var(--ss-spacing-l) 0;
}
.ss-content .ss-list .ss-option {
border-radius: var(--ss-border-radius);
}

View File

@ -1,7 +1,3 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import '../css/app.scss'
// If you want to use Phoenix channels, run `mix help phx.gen.channel` // If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below. // to get started and then uncomment the line below.
// import "./user_socket.js" // import "./user_socket.js"
@ -24,15 +20,16 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration. // Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import topbar from 'topbar'
import ShotLogChart from './shot_log_chart'
import Date from './date' import Date from './date'
import DateTime from './datetime' import DateTime from './datetime'
import ShotLogChart from './shot_log_chart'
import SlimSelect from './slim_select'
import topbar from 'topbar'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, { const liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, ShotLogChart } hooks: { Date, DateTime, ShotLogChart, SlimSelect }
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

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

View File

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

28
assets/js/slim_select.js Normal file
View File

@ -0,0 +1,28 @@
import SlimSelect from 'slim-select'
export default {
initalizeSlimSelect (el) {
// eslint-disable-next-line no-new
el.slimselect = new SlimSelect({
select: el,
settings: {
contentPosition: 'fixed'
}
})
const main = document.querySelector(`.ss-main[data-id="${el.dataset.id}"]`)
main.setAttribute('id', `${el.dataset.id}-main`)
main.setAttribute('phx-update', 'ignore')
const content = document.querySelector(`.ss-content[data-id="${el.dataset.id}"]`)
content.setAttribute('id', `${el.dataset.id}-content`)
content.setAttribute('phx-update', 'ignore')
},
updated () {
this.el.slimselect?.destroy()
this.initalizeSlimSelect(this.el)
},
mounted () {
this.initalizeSlimSelect(this.el)
}
}

13631
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,21 @@
{ {
"repository": {},
"description": " ",
"license": "MIT",
"engines": { "engines": {
"node": "v21.6.2", "node": "v23.10.0"
"npm": "10.2.4"
}, },
"scripts": { "scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin",
"format": "standard --fix", "format": "standard --fix",
"test": "standard" "test": "standard"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.7.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.3.1", "date-fns": "^4.1.0",
"phoenix": "file:../deps/phoenix", "slim-select": "^2.11.0",
"phoenix_html": "file:../deps/phoenix_html", "topbar": "^3.0.0"
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.9", "npm-check-updates": "^17.1.16",
"@babel/preset-env": "^7.23.9", "standard": "^17.1.2"
"autoprefixer": "^10.4.17",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.8.0",
"npm-check-updates": "^16.14.15",
"postcss": "^8.4.35",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.0",
"postcss-preset-env": "^9.4.0",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"standard": "^17.1.0",
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2"
} }
} }

View File

@ -1,7 +0,0 @@
module.exports = {
plugins: {
'postcss-import': {},
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,43 +0,0 @@
const colors = require('tailwindcss/colors')
module.exports = {
content: [
'../lib/**/*.{ex,heex,leex,eex}',
'./js/**/*.js'
],
theme: {
colors: {
transparent: 'transparent',
current: 'currentColor',
primary: colors.gray,
black: colors.black,
white: colors.white,
gray: colors.neutral,
indigo: colors.indigo,
red: colors.rose,
yellow: colors.amber
},
extend: {
spacing: {
128: '32rem',
192: '48rem',
256: '64rem'
},
minWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
},
maxWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
}
}
},
plugins: []
}

View File

@ -1,57 +0,0 @@
const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = (env, options) => {
const devMode = options.mode !== 'production'
return {
optimization: {
minimizer: [
new TerserPlugin({ parallel: true, extractComments: true }),
new CssMinimizerPlugin({})
]
},
entry: {
app: glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js'),
publicPath: '/js/'
},
devtool: devMode ? 'eval-cheap-module-source-map' : undefined,
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.s?css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
},
{
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource',
generator: { filename: 'fonts/[name].[ext]' }
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin({ patterns: [{ from: 'static/', to: '../' }] })
]
}
}

View File

@ -8,13 +8,14 @@
import Config import Config
config :cannery, config :cannery,
ecto_repos: [Cannery.Repo], env: :dev,
generators: [binary_id: true] ecto_repos: [Cannery.Repo]
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint # Configures the endpoint
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")], http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
@ -28,9 +29,10 @@ config :cannery, CanneryWeb.Endpoint,
config :cannery, Cannery.Application, automigrate: false config :cannery, Cannery.Application, automigrate: false
config :cannery, :generators, config :cannery, :generators,
migration: true,
binary_id: true, binary_id: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111" migration: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111",
timestamp_type: :utc_datetime_usec
# Configures the mailer # Configures the mailer
# #
@ -54,14 +56,25 @@ config :cannery, Oban,
queues: [default: 10, mailers: 20] queues: [default: 10, mailers: 20]
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
# config :esbuild, config :esbuild,
# version: "0.14.0", version: "0.17.11",
# default: [ cannery: [
# args: args:
# ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
# cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
# env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
# ] ]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.0.0",
cannery: [
args: ~w(
--input=css/style.css
--output=../priv/static/assets/style.css
),
cd: Path.expand("../assets", __DIR__)
]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,

View File

@ -2,6 +2,7 @@ import Config
# Configure your database # Configure your database
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
@ -12,21 +13,14 @@ config :cannery, Cannery.Repo,
# watchers to your application. For example, we use it # watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources. # with esbuild to bundle .js and .css sources.
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 4000],
check_origin: false, check_origin: false,
code_reloader: true, code_reloader: true,
debug_errors: true, debug_errors: true,
secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
watchers: [ watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:cannery, ~w(--sourcemap=inline --watch)]},
# esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} tailwind: {Tailwind, :install_and_run, [:cannery, ~w(--watch)]}
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
cd: Path.expand("../assets", __DIR__)
]
] ]
# ## SSL Support # ## SSL Support
@ -57,7 +51,7 @@ config :cannery, CanneryWeb.Endpoint,
config :cannery, CanneryWeb.Endpoint, config :cannery, CanneryWeb.Endpoint,
live_reload: [ live_reload: [
patterns: [ patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/cannery_web/*/.*(ex)$" ~r"lib/cannery_web/*/.*(ex)$"
] ]
@ -73,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup
debug_heex_annotations: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true

View File

@ -14,6 +14,8 @@ config :cannery, CanneryWeb.Endpoint, cache_static_manifest: "priv/static/cache_
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info
config :cannery, env: :prod
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key

View File

@ -7,13 +7,23 @@ import Config
# any compile-time configuration in here, as it won't be applied. # any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration. # The block below contains prod specific runtime configuration.
# Start the phoenix server if environment is set and running in a release # ## Using releases
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do #
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/cannery start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :cannery, CanneryWeb.Endpoint, server: true config :cannery, CanneryWeb.Endpoint, server: true
end end
config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
config :cannery, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Set default locale # Set default locale
config :gettext, :default_locale, System.get_env("LOCALE", "en_US") config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
@ -68,7 +78,7 @@ if config_env() == :prod do
System.get_env("SECRET_KEY_BASE") || System.get_env("SECRET_KEY_BASE") ||
raise """ raise """
environment variable SECRET_KEY_BASE is missing. environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: priv/random.sh
""" """
config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base

View File

@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10 timeout: 60000
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
@ -19,6 +20,8 @@ config :cannery, CanneryWeb.Endpoint,
secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK",
server: false server: false
config :cannery, env: :test
# In test we don't send emails. # In test we don't send emails.
config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
@ -32,4 +35,4 @@ config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Disable Oban # Disable Oban
config :cannery, Oban, queues: false, plugins: false config :cannery, Oban, queues: false, plugins: false, testing: :manual

View File

@ -6,4 +6,35 @@ defmodule Cannery do
Contexts are also responsible for managing your data, regardless Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others. if it comes from the database, an external API or others.
""" """
def context do
quote do
use Gettext, backend: CanneryWeb.Gettext
import Ecto.Query
alias Cannery.Accounts.User
alias Cannery.Repo
alias Ecto.{Changeset, Multi, Queryable, UUID}
end
end
def schema do
quote do
use Ecto.Schema
use Gettext, backend: CanneryWeb.Gettext
import Ecto.{Changeset, Query}
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, Queryable, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@timestamps_opts [type: :utc_datetime_usec]
end
end
@doc """
When used, dispatch to the appropriate context/schema/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end end

View File

@ -3,10 +3,9 @@ defmodule Cannery.Accounts do
The Accounts context. The Accounts context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Cannery.{Mailer, Repo} alias Cannery.Mailer
alias Cannery.Accounts.{Invite, Invites, User, UserToken} alias Cannery.Accounts.{Invite, Invites, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job alias Oban.Job
## Database getters ## Database getters
@ -219,7 +218,7 @@ defmodule Cannery.Accounts do
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query), %UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do {:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok :ok
else else
_error_tuple -> :error _error_tuple -> :error

View File

@ -5,24 +5,19 @@ defmodule Cannery.Accounts.Invite do
`:uses_left` is defined. `:uses_left` is defined.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "invites" do schema "invites" do
field :name, :string field :name, :string
field :token, :string field :token, :string
field :uses_left, :integer, default: nil field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime field :disabled_at, :utc_datetime_usec
belongs_to :created_by, User belongs_to :created_by, User
has_many :users, User has_many :users, User
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -30,12 +25,12 @@ defmodule Cannery.Accounts.Invite do
name: String.t(), name: String.t(),
token: token(), token: token(),
uses_left: integer() | nil, uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(), disabled_at: DateTime.t(),
created_by: User.t() | nil | Association.NotLoaded.t(), created_by: User.t() | nil | Association.NotLoaded.t(),
created_by_id: User.id() | nil, created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(), users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_invite :: %__MODULE__{} @type new_invite :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -3,10 +3,8 @@ defmodule Cannery.Accounts.Invites do
The Invites context. The Invites context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Ecto.Multi alias Cannery.Accounts.Invite
alias Cannery.Accounts.{Invite, User}
alias Cannery.Repo
@invite_token_length 20 @invite_token_length 20
@ -125,7 +123,7 @@ defmodule Cannery.Accounts.Invites do
end end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end end

View File

@ -3,11 +3,8 @@ defmodule Cannery.Accounts.User do
A Cannery user A Cannery user
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset alias Cannery.Accounts.Invite
import CanneryWeb.Gettext
alias Ecto.{Association, Changeset, UUID}
alias Cannery.Accounts.{Invite, User}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -20,13 +17,12 @@ defmodule Cannery.Accounts.User do
:updated_at :updated_at
]} ]}
@derive {Inspect, except: [:password]} @derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true field :password, :string, virtual: true
field :hashed_password, :string field :hashed_password, :string
field :confirmed_at, :naive_datetime field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime_usec
field :role, Ecto.Enum, values: [:admin, :user], default: :user field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string field :locale, :string
@ -34,7 +30,7 @@ defmodule Cannery.Accounts.User do
belongs_to :invite, Invite belongs_to :invite, Invite
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %User{ @type t :: %User{
@ -42,14 +38,14 @@ defmodule Cannery.Accounts.User do
email: String.t(), email: String.t(),
password: String.t(), password: String.t(),
hashed_password: String.t(), hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(), confirmed_at: DateTime.t(),
role: role(), role: role(),
locale: String.t() | nil, locale: String.t() | nil,
created_invites: [Invite.t()] | Association.NotLoaded.t(), created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(), invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil, invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_user :: %User{} @type new_user :: %User{}
@type id :: UUID.t() @type id :: UUID.t()
@ -141,7 +137,7 @@ defmodule Cannery.Accounts.User do
|> cast(attrs, [:email]) |> cast(attrs, [:email])
|> validate_email() |> validate_email()
|> case do |> case do
%{changes: %{email: _}} = changeset -> changeset %{changes: %{email: _email}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
end end
end end
@ -172,7 +168,7 @@ defmodule Cannery.Accounts.User do
""" """
@spec confirm_changeset(t() | changeset()) :: changeset() @spec confirm_changeset(t() | changeset()) :: changeset()
def confirm_changeset(user_or_changeset) do def confirm_changeset(user_or_changeset) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
user_or_changeset |> change(confirmed_at: now) user_or_changeset |> change(confirmed_at: now)
end end
@ -198,6 +194,8 @@ defmodule Cannery.Accounts.User do
""" """
@spec validate_current_password(changeset(), String.t()) :: changeset() @spec validate_current_password(changeset(), String.t()) :: changeset()
def validate_current_password(changeset, password) do def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password), if valid_password?(changeset.data, password),
do: changeset, do: changeset,
else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))

View File

@ -3,10 +3,7 @@ defmodule Cannery.Accounts.UserToken do
Schema for a user's session token Schema for a user's session token
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Query
alias Cannery.Accounts.User
alias Ecto.{Association, UUID}
@hash_algorithm :sha256 @hash_algorithm :sha256
@rand_size 32 @rand_size 32
@ -18,8 +15,6 @@ defmodule Cannery.Accounts.UserToken do
@change_email_validity_in_days 7 @change_email_validity_in_days 7
@session_validity_in_days 60 @session_validity_in_days 60
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do schema "users_tokens" do
field :token, :binary field :token, :binary
field :context, :string field :context, :string
@ -37,7 +32,7 @@ defmodule Cannery.Accounts.UserToken do
sent_to: String.t(), sent_to: String.t(),
user: User.t() | Association.NotLoaded.t(), user: User.t() | Association.NotLoaded.t(),
user_id: User.id() | nil, user_id: User.id() | nil,
inserted_at: NaiveDateTime.t() inserted_at: DateTime.t()
} }
@type new_user_token :: %__MODULE__{} @type new_user_token :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@ -155,7 +150,7 @@ defmodule Cannery.Accounts.UserToken do
from t in __MODULE__, where: t.user_id == ^user.id from t in __MODULE__, where: t.user_id == ^user.id
end end
def user_and_contexts_query(user, [_ | _] = contexts) do def user_and_contexts_query(user, [_first | _rest] = contexts) do
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end end
end end

View File

@ -3,14 +3,14 @@ defmodule Cannery.ActivityLog do
The ActivityLog context. The ActivityLog context.
""" """
import Ecto.Query, warn: false use Cannery, :context
alias Cannery.Ammo.{Pack, Type} alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type}
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo}
alias Ecto.{Multi, Queryable}
@type list_shot_records_option :: @type list_shot_records_option ::
{:search, String.t() | nil} {:search, String.t() | nil}
| {:class, Type.class() | :all | nil} | {:class, Type.class() | :all | nil}
| {:start_date, String.t() | nil}
| {:end_date, String.t() | nil}
| {:pack_id, Pack.id() | nil} | {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()] @type list_shot_records_options :: [list_shot_records_option()]
@ -51,6 +51,8 @@ defmodule Cannery.ActivityLog do
|> list_shot_records_search(Keyword.get(opts, :search)) |> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_records_class(Keyword.get(opts, :class)) |> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id)) |> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> list_shot_records_start_date(Keyword.get(opts, :start_date))
|> list_shot_records_end_date(Keyword.get(opts, :end_date))
|> Repo.all() |> Repo.all()
end end
@ -102,6 +104,20 @@ defmodule Cannery.ActivityLog do
defp list_shot_records_pack_id(query, _all), do: query defp list_shot_records_pack_id(query, _all), do: query
@spec list_shot_records_start_date(Queryable.t(), String.t() | nil) :: Queryable.t()
defp list_shot_records_start_date(query, start_date) when start_date |> is_binary() do
query |> where([sr: sr], sr.date >= ^Date.from_iso8601!(start_date))
end
defp list_shot_records_start_date(query, _all), do: query
@spec list_shot_records_end_date(Queryable.t(), String.t() | nil) :: Queryable.t()
defp list_shot_records_end_date(query, end_date) when end_date |> is_binary() do
query |> where([sr: sr], sr.date <= ^Date.from_iso8601!(end_date))
end
defp list_shot_records_end_date(query, _all), do: query
@doc """ @doc """
Returns a count of shot records. Returns a count of shot records.

View File

@ -3,11 +3,8 @@ defmodule Cannery.ActivityLog.ShotRecord do
A shot record records a group of ammo shot during a range trip A shot record records a group of ammo shot during a range trip
""" """
use Ecto.Schema use Cannery, :schema
import CanneryWeb.Gettext alias Cannery.{Ammo, Ammo.Pack}
import Ecto.Changeset
alias Cannery.{Accounts.User, Ammo, Ammo.Pack}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -27,7 +24,7 @@ defmodule Cannery.ActivityLog.ShotRecord do
field :user_id, :binary_id field :user_id, :binary_id
field :pack_id, :binary_id field :pack_id, :binary_id
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -37,8 +34,8 @@ defmodule Cannery.ActivityLog.ShotRecord do
date: Date.t() | nil, date: Date.t() | nil,
pack_id: Pack.id(), pack_id: Pack.id(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_shot_record :: %__MODULE__{} @type new_shot_record :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -3,13 +3,11 @@ defmodule Cannery.Ammo do
The Ammo context. The Ammo context.
""" """
import CanneryWeb.Gettext use Cannery, :context
import Ecto.Query, warn: false alias Cannery.Containers
alias Cannery.{Accounts.User, Containers, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag} alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Cannery.{ActivityLog, ActivityLog.ShotRecord} alias Cannery.{ActivityLog, ActivityLog.ShotRecord}
alias Cannery.Ammo.{Pack, Type} alias Cannery.Ammo.{Pack, Type}
alias Ecto.{Changeset, Queryable}
@pack_create_limit 10_000 @pack_create_limit 10_000
@pack_preloads [:type] @pack_preloads [:type]
@ -549,7 +547,7 @@ defmodule Cannery.Ammo do
@spec list_packs_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t() @spec list_packs_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t()
defp list_packs_staged(query, staged) when staged |> is_boolean(), defp list_packs_staged(query, staged) when staged |> is_boolean(),
do: query |> where([p: p], p.staged == ^staged) do: query |> where([c: c], c.staged == ^staged)
defp list_packs_staged(query, _nil), do: query defp list_packs_staged(query, _nil), do: query
@ -922,7 +920,7 @@ defmodule Cannery.Ammo do
multiplier <= @pack_create_limit and multiplier <= @pack_create_limit and
type_id |> is_binary() and type_id |> is_binary() and
container_id |> is_binary() do container_id |> is_binary() do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
changesets = changesets =
Enum.map(1..multiplier, fn _count -> Enum.map(1..multiplier, fn _count ->
@ -959,25 +957,79 @@ defmodule Cannery.Ammo do
defp do_create_packs( defp do_create_packs(
%{"type_id" => type_id, "container_id" => container_id} = attrs, %{"type_id" => type_id, "container_id" => container_id} = attrs,
_multiplier, multiplier,
user user
) )
when is_binary(type_id) and is_binary(container_id) do when is_binary(type_id) and is_binary(container_id) do
changeset = %Pack{}
%Pack{} |> Pack.create_changeset(
|> Pack.create_changeset( get_type!(type_id, user),
get_type!(type_id, user), Containers.get_container!(container_id, user),
Containers.get_container!(container_id, user), user,
user, attrs
attrs )
) |> maybe_add_multiplier_error(multiplier)
|> Changeset.add_error(:multiplier, dgettext("errors", "Invalid multiplier")) |> Changeset.apply_action(:insert)
{:error, changeset}
end end
defp do_create_packs(invalid_attrs, _multiplier, user) do defp do_create_packs(
{:error, %Pack{} |> Pack.create_changeset(nil, nil, user, invalid_attrs)} %{"type_id" => type_id} = attrs,
multiplier,
user
)
when is_binary(type_id) do
%Pack{}
|> Pack.create_changeset(
get_type!(type_id, user),
nil,
user,
attrs
)
|> maybe_add_multiplier_error(multiplier)
|> Changeset.apply_action(:insert)
end
defp do_create_packs(
%{"container_id" => container_id} = attrs,
multiplier,
user
)
when is_binary(container_id) do
%Pack{}
|> Pack.create_changeset(
nil,
Containers.get_container!(container_id, user),
user,
attrs
)
|> maybe_add_multiplier_error(multiplier)
|> Changeset.apply_action(:insert)
end
defp do_create_packs(invalid_attrs, multiplier, user) do
%Pack{}
|> Pack.create_changeset(nil, nil, user, invalid_attrs)
|> maybe_add_multiplier_error(multiplier)
|> Changeset.apply_action(:insert)
end
defp maybe_add_multiplier_error(changeset, multiplier)
when multiplier >= 1 and
multiplier <= @pack_create_limit do
changeset
end
defp maybe_add_multiplier_error(changeset, multiplier) do
changeset
|> Changeset.add_error(
:multiplier,
dgettext(
"errors",
"Invalid number of copies, must be between 1 and %{max}. Was %{multiplier}",
max: @pack_create_limit,
multiplier: multiplier
)
)
end end
@spec preload_pack(Pack.t()) :: Pack.t() @spec preload_pack(Pack.t()) :: Pack.t()

View File

@ -6,55 +6,46 @@ defmodule Cannery.Ammo.Pack do
amount paid for that ammunition, or what condition it is in amount paid for that ammunition, or what condition it is in
""" """
use Ecto.Schema use Cannery, :schema
import CanneryWeb.Gettext alias Cannery.{Ammo.Type, Containers, Containers.Container}
import Ecto.Changeset
alias Cannery.Ammo.Type
alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
:id, :container_id,
:count, :count,
:id,
:lot_number,
:notes, :notes,
:price_paid, :price_paid,
:lot_number, :type_id
:staged,
:type_id,
:container_id
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "packs" do schema "packs" do
field :count, :integer field :count, :integer
field :lot_number, :string
field :notes, :string field :notes, :string
field :price_paid, :float field :price_paid, :float
field :staged, :boolean, default: false
field :lot_number, :string
field :purchased_on, :date field :purchased_on, :date
belongs_to :type, Type belongs_to :type, Type
field :container_id, :binary_id field :container_id, :binary_id
field :user_id, :binary_id field :user_id, :binary_id
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: id(),
count: integer, count: integer,
id: id(),
lot_number: String.t() | nil,
notes: String.t() | nil, notes: String.t() | nil,
price_paid: float() | nil, price_paid: float() | nil,
staged: boolean(),
lot_number: String.t() | nil,
purchased_on: Date.t(), purchased_on: Date.t(),
type: Type.t() | nil, type: Type.t() | nil,
type_id: Type.id(), type_id: Type.id(),
container_id: Container.id(), container_id: Container.id(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_pack :: %__MODULE__{} @type new_pack :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@ -70,34 +61,55 @@ defmodule Cannery.Ammo.Pack do
) :: changeset() ) :: changeset()
def create_changeset( def create_changeset(
pack, pack,
%Type{id: type_id}, type,
%Container{id: container_id, user_id: user_id}, container,
%User{id: user_id}, %User{id: user_id},
attrs attrs
) )
when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do when is_binary(user_id) do
type_id =
case type do
%Type{id: type_id} when is_binary(type_id) ->
type_id
_invalid_type ->
nil
end
container_id =
case container do
%Container{id: container_id, user_id: ^user_id} when is_binary(container_id) ->
container_id
_invalid_container ->
nil
end
pack pack
|> change(type_id: type_id) |> change(type_id: type_id)
|> change(user_id: user_id)
|> change(container_id: container_id) |> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number]) |> change(user_id: user_id)
|> cast(attrs, [
:count,
:lot_number,
:notes,
:price_paid,
:purchased_on
])
|> validate_required(:type_id, message: dgettext("errors", "Please select a valid type"))
|> validate_required(:container_id,
message: dgettext("errors", "Please select a valid container")
)
|> validate_number(:count, greater_than: 0) |> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0) |> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_length(:lot_number, max: 255) |> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id]) |> validate_required([
end :container_id,
:count,
@doc """ :purchased_on,
Invalid changeset, used to prompt user to select type and container :type_id,
""" :user_id
def create_changeset(pack, _invalid_type, _invalid_container, _invalid_user, attrs) do ])
pack
|> cast(attrs, [:type_id, :container_id])
|> validate_required([:type_id, :container_id])
|> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_length(:lot_number, max: 255)
|> add_error(:invalid, dgettext("errors", "Please select a type and container"))
end end
@doc false @doc false
@ -105,19 +117,22 @@ defmodule Cannery.Ammo.Pack do
def update_changeset(pack, attrs, user) do def update_changeset(pack, attrs, user) do
pack pack
|> cast(attrs, [ |> cast(attrs, [
:container_id,
:count, :count,
:price_paid,
:notes,
:staged,
:purchased_on,
:lot_number, :lot_number,
:container_id :notes,
:price_paid,
:purchased_on
]) ])
|> validate_number(:count, greater_than_or_equal_to: 0) |> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0) |> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_container_id(user) |> validate_container_id(user)
|> validate_length(:lot_number, max: 255) |> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :container_id]) |> validate_required([
:container_id,
:count,
:purchased_on
])
end end
defp validate_container_id(changeset, user) do defp validate_container_id(changeset, user) do
@ -137,7 +152,7 @@ defmodule Cannery.Ammo.Pack do
@spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset() @spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset()
def range_changeset(pack, attrs) do def range_changeset(pack, attrs) do
pack pack
|> cast(attrs, [:count, :staged]) |> cast(attrs, [:count])
|> validate_required([:count, :staged]) |> validate_required([:count])
end end
end end

View File

@ -5,11 +5,8 @@ defmodule Cannery.Ammo.Type do
Contains statistical information about the ammunition. Contains statistical information about the ammunition.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Cannery.Ammo.Pack alias Cannery.Ammo.Pack
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -46,8 +43,6 @@ defmodule Cannery.Ammo.Type do
:shot_charge_weight, :shot_charge_weight,
:dram_equivalent :dram_equivalent
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "types" do schema "types" do
field :name, :string field :name, :string
field :desc, :string field :desc, :string
@ -95,7 +90,7 @@ defmodule Cannery.Ammo.Type do
field :user_id, :binary_id field :user_id, :binary_id
has_many :packs, Pack has_many :packs, Pack
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -134,8 +129,8 @@ defmodule Cannery.Ammo.Type do
dram_equivalent: String.t() | nil, dram_equivalent: String.t() | nil,
user_id: User.id(), user_id: User.id(),
packs: [Pack.t()] | nil, packs: [Pack.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_type :: %__MODULE__{} @type new_type :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -15,6 +15,7 @@ defmodule Cannery.Application do
CanneryWeb.Telemetry, CanneryWeb.Telemetry,
# Start the PubSub system # Start the PubSub system
{Phoenix.PubSub, name: Cannery.PubSub}, {Phoenix.PubSub, name: Cannery.PubSub},
{DNSCluster, query: Application.get_env(:cannery, :dns_cluster_query) || :ignore},
# Start the Endpoint (http/https) # Start the Endpoint (http/https)
CanneryWeb.Endpoint, CanneryWeb.Endpoint,
# Add Oban # Add Oban

View File

@ -3,15 +3,15 @@ defmodule Cannery.Containers do
The Containers context. The Containers context.
""" """
import CanneryWeb.Gettext use Cannery, :context
import Ecto.Query, warn: false alias Cannery.Ammo.Pack
alias Cannery.{Accounts.User, Ammo.Pack, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag} alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.{Changeset, Queryable}
@container_preloads [:tags] @container_preloads [:tags]
@type list_containers_option :: {:search, String.t() | nil} @type list_containers_option ::
{:search, String.t() | nil}
| {:staged, boolean() | nil}
@type list_containers_options :: [list_containers_option()] @type list_containers_options :: [list_containers_option()]
@doc """ @doc """
@ -22,7 +22,10 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123}) iex> list_containers(%User{id: 123})
[%Container{}, ...] [%Container{}, ...]
iex> list_containers(%User{id: 123}, search: "cool") iex> list_containers(%User{id: 123},
...> search: "cool",
...> staged: true
...> )
[%Container{name: "my cool container"}, ...] [%Container{name: "my cool container"}, ...]
""" """
@ -39,9 +42,16 @@ defmodule Cannery.Containers do
preload: ^@container_preloads preload: ^@container_preloads
) )
|> list_containers_search(Keyword.get(opts, :search)) |> list_containers_search(Keyword.get(opts, :search))
|> list_containers_staged(Keyword.get(opts, :staged))
|> Repo.all() |> Repo.all()
end end
@spec list_containers_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t()
defp list_containers_staged(query, staged) when staged |> is_boolean(),
do: query |> where([c: c], c.staged == ^staged)
defp list_containers_staged(query, _nil), do: query
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t() @spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_containers_search(query, search) when search in ["", nil], defp list_containers_search(query, search) when search in ["", nil],
do: query |> order_by([c: c], c.name) do: query |> order_by([c: c], c.name)

View File

@ -3,45 +3,44 @@ defmodule Cannery.Containers.Container do
A container that holds ammunition and belongs to a user. A container that holds ammunition and belongs to a user.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset alias Cannery.{Containers.ContainerTag, Containers.Tag}
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
:id,
:name,
:desc, :desc,
:id,
:location, :location,
:type, :name,
:tags :staged,
:tags,
:type
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "containers" do schema "containers" do
field :name, :string
field :desc, :string field :desc, :string
field :location, :string field :location, :string
field :name, :string
field :staged, :boolean, default: false
field :type, :string field :type, :string
field :user_id, :binary_id field :user_id, :binary_id
many_to_many :tags, Tag, join_through: ContainerTag many_to_many :tags, Tag, join_through: ContainerTag
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t(), desc: String.t(),
id: id(),
location: String.t(), location: String.t(),
name: String.t(),
staged: boolean(),
type: String.t(), type: String.t(),
user_id: User.id(), user_id: User.id(),
tags: [Tag.t()] | nil, tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_container :: %__MODULE__{} @type new_container :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@ -52,19 +51,40 @@ defmodule Cannery.Containers.Container do
def create_changeset(container, %User{id: user_id}, attrs) do def create_changeset(container, %User{id: user_id}, attrs) do
container container
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [
:desc,
:location,
:name,
:staged,
:type
])
|> validate_length(:name, max: 255) |> validate_length(:name, max: 255)
|> validate_length(:type, max: 255) |> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id]) |> validate_required([
:name,
:staged,
:type,
:user_id
])
end end
@doc false @doc false
@spec update_changeset(t() | new_container(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_container(), attrs :: map()) :: changeset()
def update_changeset(container, attrs) do def update_changeset(container, attrs) do
container container
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [
:desc,
:location,
:name,
:staged,
:type
])
|> validate_length(:name, max: 255) |> validate_length(:name, max: 255)
|> validate_length(:type, max: 255) |> validate_length(:type, max: 255)
|> validate_required([:name, :type]) |> validate_required([
:name,
:staged,
:type
])
end end
end end

View File

@ -4,18 +4,14 @@ defmodule Cannery.Containers.ContainerTag do
Cannery.Containers.Tag. Cannery.Containers.Tag.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Containers.{Container, Tag} alias Cannery.Containers.{Container, Tag}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "container_tags" do schema "container_tags" do
belongs_to :container, Container belongs_to :container, Container
belongs_to :tag, Tag belongs_to :tag, Tag
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -24,8 +20,8 @@ defmodule Cannery.Containers.ContainerTag do
container_id: Container.id(), container_id: Container.id(),
tag: Tag.t(), tag: Tag.t(),
tag_id: Tag.id(), tag_id: Tag.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_container_tag :: %__MODULE__{} @type new_container_tag :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()

View File

@ -4,10 +4,7 @@ defmodule Cannery.Containers.Tag do
text and bg colors. text and bg colors.
""" """
use Ecto.Schema use Cannery, :schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
@ -16,8 +13,6 @@ defmodule Cannery.Containers.Tag do
:bg_color, :bg_color,
:text_color :text_color
]} ]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "tags" do schema "tags" do
field :name, :string field :name, :string
field :bg_color, :string field :bg_color, :string
@ -25,7 +20,7 @@ defmodule Cannery.Containers.Tag do
field :user_id, :binary_id field :user_id, :binary_id
timestamps() timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -34,8 +29,8 @@ defmodule Cannery.Containers.Tag do
bg_color: String.t(), bg_color: String.t(),
text_color: String.t(), text_color: String.t(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: DateTime.t(),
updated_at: NaiveDateTime.t() updated_at: DateTime.t()
} }
@type new_tag() :: %__MODULE__{} @type new_tag() :: %__MODULE__{}
@type id() :: UUID.t() @type id() :: UUID.t()

View File

@ -7,8 +7,8 @@ defmodule Cannery.Email do
`lib/cannery_web/components/layouts/email_text.txt.eex` for text emails. `lib/cannery_web/components/layouts/email_text.txt.eex` for text emails.
""" """
use Gettext, backend: CanneryWeb.Gettext
import Swoosh.Email import Swoosh.Email
import CanneryWeb.Gettext
import Phoenix.Template import Phoenix.Template
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias CanneryWeb.{EmailHTML, Layouts} alias CanneryWeb.{EmailHTML, Layouts}

View File

@ -14,7 +14,7 @@ defmodule Cannery.Logger do
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode() |> pretty_encode()
Logger.error("#{meta.reason}: #{data}") Logger.error("Oban exception: #{data}")
end end
def handle_event([:oban, :job, :start], measure, meta, _config) do def handle_event([:oban, :job, :start], measure, meta, _config) do

View File

@ -17,7 +17,7 @@ defmodule CanneryWeb do
those modules here. those modules here.
""" """
def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
def router do def router do
quote do quote do
@ -42,9 +42,10 @@ defmodule CanneryWeb do
formats: [:html, :json], formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts] layouts: [html: CanneryWeb.Layouts]
use Gettext, backend: CanneryWeb.Gettext
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn import Plug.Conn
import CanneryWeb.Gettext
unquote(verified_routes()) unquote(verified_routes())
end end
@ -84,8 +85,9 @@ defmodule CanneryWeb do
defp html_helpers do defp html_helpers do
quote do quote do
use PhoenixHTMLHelpers use PhoenixHTMLHelpers
use Gettext, backend: CanneryWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form} import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers} import CanneryWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
# Shortcut for generating JS commands # Shortcut for generating JS commands
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS

View File

@ -33,7 +33,9 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
) do ) do
params = shot_record_params |> process_params(pack) params = shot_record_params |> process_params(pack)
changeset = %ShotRecord{} |> ShotRecord.create_changeset(current_user, pack, params) changeset =
%ShotRecord{}
|> ShotRecord.create_changeset(current_user, pack, params)
changeset = changeset =
case changeset |> Changeset.apply_action(:validate) do case changeset |> Changeset.apply_action(:validate) do

View File

@ -1,6 +1,6 @@
<div> <div>
<h2 class="mb-8 text-center title text-xl text-primary-600"> <h2 class="mb-8 text-center title text-xl text-primary-600">
<%= gettext("Record shots") %> {gettext("Record shots")}
</h2> </h2>
<.form <.form
@ -13,49 +13,50 @@
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?()} :if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> {changeset_errors(@changeset)}
</div> </div>
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> {label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600")}
<%= number_input(f, :ammo_left, {number_input(f, :ammo_left,
min: 0, min: 0,
max: @pack.count - 1, max: @pack.count - 1,
placeholder: gettext("Rounds left"), placeholder: gettext("Rounds left"),
class: "input input-primary" class: "input input-primary"
) %> )}
<button <button
type="button" type="button"
class="mx-2 my-1 text-sm btn btn-primary" class="mx-2 my-1 text-sm btn btn-primary"
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")} phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")}
> >
<%= gettext("Used up!") %> {gettext("Used up!")}
</button> </button>
<%= error_tag(f, :ammo_left, "col-span-3") %> {error_tag(f, :ammo_left, "col-span-3")}
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> {label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600")}
<%= textarea(f, :notes, {textarea(f, :notes,
id: "add-shot-record-form-notes",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
id: "add-shot-record-form-notes",
maxlength: 255, maxlength: 255,
placeholder: gettext("Really great weather"), phx_debounce: 300,
phx_update: "ignore" phx_update: "ignore",
) %> placeholder: gettext("Really great weather")
<%= error_tag(f, :notes, "col-span-3") %> )}
{error_tag(f, :notes, "col-span-3")}
<%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %> {label(f, :date, gettext("Date"), class: "title text-lg text-primary-600")}
<%= date_input(f, :date, {date_input(f, :date,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
phx_update: "ignore", phx_update: "ignore",
value: Date.utc_today() value: Date.utc_today()
) %> )}
<%= error_tag(f, :notes, "col-span-3") %> {error_tag(f, :notes, "col-span-3")}
<%= submit(dgettext("actions", "Save"), {submit(dgettext("actions", "Save"),
class: "mx-auto btn btn-primary col-span-3", class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "Saving...") phx_disable_with: dgettext("prompts", "Saving...")
) %> )}
</.form> </.form>
</div> </div>

View File

@ -4,6 +4,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Containers.Container} alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -13,6 +14,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:containers) => [Container.t()], optional(:containers) => [Container.t()],
optional(:range) => Rendered.t(),
optional(:tag_actions) => Rendered.t(), optional(:tag_actions) => Rendered.t(),
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
optional(any()) => any() optional(any()) => any()
@ -23,6 +25,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:range, fn -> [] end)
|> assign_new(:tag_actions, fn -> [] end) |> assign_new(:tag_actions, fn -> [] end)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_containers() |> display_containers()
@ -35,6 +38,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns: %{ assigns: %{
containers: containers, containers: containers,
current_user: current_user, current_user: current_user,
range: range,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions actions: actions
} }
@ -62,13 +66,22 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end) end)
|> Enum.concat([ |> Enum.concat([
%{label: gettext("Packs"), key: :packs, type: :integer}, %{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :integer}, %{label: gettext("Rounds"), key: :rounds, type: :integer}
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
]) ])
|> Enum.concat(
[
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
]
|> TableComponent.maybe_compose_columns(
%{label: gettext("Range"), key: :range},
range != []
)
)
extra_data = %{ extra_data = %{
current_user: current_user, current_user: current_user,
range: range,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions, actions: actions,
pack_count: pack_count:
@ -122,7 +135,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
~H""" ~H"""
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<.link navigate={~p"/container/#{@id}"} class="link"> <.link navigate={~p"/container/#{@id}"} class="link">
<%= @name %> {@name}
</.link> </.link>
</div> </div>
"""} """}
@ -136,6 +149,15 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
round_count |> Map.get(container_id, 0) round_count |> Map.get(container_id, 0)
end end
defp get_value_for_key(:range, %{staged: staged} = container, %{range: range}) do
assigns = %{range: range, container: container}
{staged,
~H"""
{render_slot(@range, @container)}
"""}
end
defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do
assigns = %{tag_actions: tag_actions, container: container} assigns = %{tag_actions: tag_actions, container: container}
@ -150,7 +172,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= render_slot(@tag_actions, @container) %> {render_slot(@tag_actions, @container)}
</div> </div>
"""} """}
end end
@ -159,7 +181,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns = %{actions: actions, container: container} assigns = %{actions: actions, container: container}
~H""" ~H"""
<%= render_slot(@actions, @container) %> {render_slot(@actions, @container)}
""" """
end end

View File

@ -4,7 +4,8 @@ defmodule CanneryWeb.CoreComponents do
""" """
use Phoenix.Component use Phoenix.Component
use CanneryWeb, :verified_routes use CanneryWeb, :verified_routes
import CanneryWeb.{Gettext, HTMLHelpers} use Gettext, backend: CanneryWeb.Gettext
import CanneryWeb.HTMLHelpers
alias Cannery.{Accounts, Accounts.Invite, Accounts.User} alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.Pack} alias Cannery.{Ammo, Ammo.Pack}
alias Cannery.{Containers.Container, Containers.Tag} alias Cannery.{Containers.Container, Containers.Tag}
@ -135,14 +136,26 @@ defmodule CanneryWeb.CoreComponents do
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """ @doc """
Phoenix.Component for a <time> element that renders the naivedatetime in the Phoenix.Component for a <time> element that renders the DateTime in the
user's local timezone user's local timezone
""" """
def datetime(assigns) def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() attr :name, :string, required: true
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) attr :start_date, :string,
default: Date.utc_today() |> Date.shift(year: -1) |> Date.to_iso8601()
attr :end_date, :string, default: Date.utc_today() |> Date.to_iso8601()
@doc """
Phoenix.Component for an element that generates date fields for a range
"""
def date_range(assigns)
@spec cast_datetime(DateTime.t() | nil) :: String.t()
defp cast_datetime(%DateTime{} = datetime) do
datetime |> DateTime.to_iso8601(:extended)
end end
defp cast_datetime(_datetime), do: "" defp cast_datetime(_datetime), do: ""

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<div class="flex items-center mx-4 my-2 space-x-1">
<input
class="w-40 text-center input input-primary"
name={"#{@name}_start"}
type="date"
value={@start_date}
/>
<span>—</span>
<input
class="w-40 text-center input input-primary"
name={"#{@name}_end"}
type="date"
value={@end_date}
/>
</div>

View File

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

View File

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

View File

@ -29,7 +29,7 @@
patch={@return_to} patch={@return_to}
id="close" id="close"
class="absolute top-8 right-10 class="absolute top-8 right-10
text-gray-500 hover:text-gray-800 text-zinc-500 hover:text-zinc-800
transition-all duration-500 ease-in-out" transition-all duration-500 ease-in-out"
phx-remove={hide_modal()} phx-remove={hide_modal()}
aria-label={gettext("Close modal")} aria-label={gettext("Close modal")}
@ -38,7 +38,7 @@
</.link> </.link>
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@
<span class="mx-2 my-1"> <span class="mx-2 my-1">
| |
</span> </span>
<%= @title_content %> {@title_content}
<% end %> <% end %>
</div> </div>
@ -25,37 +25,37 @@
<%= if @current_user do %> <%= if @current_user do %>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/tags"} class="text-white hover:underline"> <.link navigate={~p"/tags"} class="text-white hover:underline">
<%= gettext("Tags") %> {gettext("Tags")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/containers"} class="text-white hover:underline"> <.link navigate={~p"/containers"} class="text-white hover:underline">
<%= gettext("Containers") %> {gettext("Containers")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/catalog"} class="text-white hover:underline"> <.link navigate={~p"/catalog"} class="text-white hover:underline">
<%= gettext("Catalog") %> {gettext("Catalog")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/ammo"} class="text-white hover:underline"> <.link navigate={~p"/ammo"} class="text-white hover:underline">
<%= gettext("Ammo") %> {gettext("Ammo")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/range"} class="text-white hover:underline"> <.link navigate={~p"/range"} class="text-white hover:underline">
<%= gettext("Range") %> {gettext("Range")}
</.link> </.link>
</li> </li>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1"> <li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-white hover:underline"> <.link navigate={~p"/invites"} class="text-white hover:underline">
<%= gettext("Invites") %> {gettext("Invites")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link href={~p"/users/settings"} class="text-white hover:underline truncate"> <.link href={~p"/users/settings"} class="text-white hover:underline truncate">
<%= @current_user.email %> {@current_user.email}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
@ -86,12 +86,12 @@
<% else %> <% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1"> <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link href={~p"/users/register"} class="text-white hover:underline truncate"> <.link href={~p"/users/register"} class="text-white hover:underline truncate">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link href={~p"/users/log_in"} class="text-white hover:underline truncate"> <.link href={~p"/users/log_in"} class="text-white hover:underline truncate">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</.link> </.link>
</li> </li>
<% end %> <% end %>

View File

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

View File

@ -1,23 +1,23 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.75em; font-size: 1.5em;"> <span style="margin-bottom: 0.75em; font-size: 1.5em;">
<%= dgettext("emails", "Hi %{email},", email: @user.email) %> {dgettext("emails", "Hi %{email},", email: @user.email)}
</span> </span>
<br /> <br />
<span style="margin-bottom: 1em; font-size: 1.25em;"> <span style="margin-bottom: 1em; font-size: 1.25em;">
<%= dgettext("emails", "Welcome to Cannery") %> {dgettext("emails", "Welcome to Cannery")}
</span> </span>
<br /> <br />
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> {dgettext("emails", "You can confirm your account by visiting the URL below:")}
<br /> <br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br /> <br />
<%= dgettext("emails", "If you didn't create an account at Cannery, please ignore this.") %> {dgettext("emails", "If you didn't create an account at Cannery, please ignore this.")}
</div> </div>

View File

@ -1,17 +1,17 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;"> <span style="margin-bottom: 0.5em; font-size: 1.5em;">
<%= dgettext("emails", "Hi %{email},", email: @user.email) %> {dgettext("emails", "Hi %{email},", email: @user.email)}
</span> </span>
<br /> <br />
<%= dgettext("emails", "You can reset your password by visiting the URL below:") %> {dgettext("emails", "You can reset your password by visiting the URL below:")}
<br /> <br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br /> <br />
<%= dgettext("emails", "If you didn't request this change from Cannery, please ignore this.") %> {dgettext("emails", "If you didn't request this change from Cannery, please ignore this.")}
</div> </div>

View File

@ -1,20 +1,20 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;"> <span style="margin-bottom: 0.5em; font-size: 1.5em;">
<%= dgettext("emails", "Hi %{email},", email: @user.email) %> {dgettext("emails", "Hi %{email},", email: @user.email)}
</span> </span>
<br /> <br />
<%= dgettext("emails", "You can change your email by visiting the URL below:") %> {dgettext("emails", "You can change your email by visiting the URL below:")}
<br /> <br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a>
<br /> <br />
<%= dgettext( {dgettext(
"emails", "emails",
"If you didn't request this change from Cannery, please ignore this." "If you didn't request this change from Cannery, please ignore this."
) %> )}
</div> </div>

View File

@ -10,7 +10,7 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="info" phx-value-key="info"
> >
<%= live_flash(@flash, "info") %> {Phoenix.Flash.get(@flash, :info)}
</p> </p>
<p <p
@ -20,13 +20,13 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="error" phx-value-key="error"
> >
<%= live_flash(@flash, "error") %> {Phoenix.Flash.get(@flash, :error)}
</p> </p>
</div> </div>
</header> </header>
<div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch">
<%= @inner_content %> {@inner_content}
</div> </div>
</main> </main>
@ -40,6 +40,6 @@
<i class="fas fa-fade text-md fa-satellite-dish"></i> <i class="fas fa-fade text-md fa-satellite-dish"></i>
<h1 class="title text-md title-primary-500"> <h1 class="title text-md title-primary-500">
<%= gettext("Reconnecting...") %> {gettext("Reconnecting...")}
</h1> </h1>
</div> </div>

View File

@ -1,19 +1,19 @@
<html> <html>
<head> <head>
<title> <title>
<%= @email.subject %> {@email.subject}
</title> </title>
</head> </head>
<body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;">
<%= @inner_content %> {@inner_content}
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" />
<a style="color: rgb(31, 31, 31);" href={~p"/"}> <a style="color: rgb(31, 31, 31);" href={~p"/"}>
<%= dgettext( {dgettext(
"emails", "emails",
"This email was sent from Cannery, the self-hosted firearm tracker website." "This email was sent from Cannery, the self-hosted firearm tracker website."
) %> )}
</a> </a>
</body> </body>
</html> </html>

View File

@ -1 +1 @@
<%= @inner_block %> {@inner_block}

View File

@ -1,20 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="m-0 p-0 w-full h-full bg-white"> <html lang="en" class="p-0 m-0 w-full h-full bg-white [scrollbar-gutter:stable]">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="csrf-token" content={get_csrf_token()} />
<%= csrf_meta_tag() %>
<link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} /> <link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} />
<.live_title suffix={" | #{gettext("Cannery")}"}> <.live_title suffix={" | #{gettext("Cannery")}"}>
<%= assigns[:page_title] || gettext("Cannery") %> {assigns[:page_title] || gettext("Cannery")}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/style.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
</head> </head>
<body class="m-0 p-0 w-full h-full subpixel-antialiased"> <body class="p-0 m-0 w-full h-full subpixel-antialiased">
<%= @inner_content %> {@inner_content}
</body> </body>
</html> </html>

View File

@ -74,17 +74,17 @@ defmodule CanneryWeb.Components.MovePackComponent do
~H""" ~H"""
<div class="w-full flex flex-col space-y-8 justify-center items-center"> <div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600"> <h2 class="mb-8 text-center title text-xl text-primary-600">
<%= dgettext("actions", "Move ammo") %> {dgettext("actions", "Move ammo")}
</h2> </h2>
<%= if @containers |> Enum.empty?() do %> <%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= gettext("No other containers") %> {gettext("No other containers")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
<.link navigate={~p"/containers/new"} class="btn btn-primary"> <.link navigate={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %> {dgettext("actions", "Add another container!")}
</.link> </.link>
<% else %> <% else %>
<.live_component <.live_component
@ -120,7 +120,7 @@ defmodule CanneryWeb.Components.MovePackComponent do
phx-target={@myself} phx-target={@myself}
phx-value-container_id={@container.id} phx-value-container_id={@container.id}
> >
<%= dgettext("actions", "Select") %> {dgettext("actions", "Select")}
</button> </button>
</div> </div>
""" """

View File

@ -170,7 +170,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
{type_name, {type_name,
~H""" ~H"""
<%= render_slot(@type_block, @type) %> {render_slot(@type_block, @type)}
"""} """}
end end
@ -196,18 +196,17 @@ defmodule CanneryWeb.Components.PackTableComponent do
<%= if @last_used_date do %> <%= if @last_used_date do %>
<.date id={"#{@id}-last-used-date"} date={@last_used_date} /> <.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %> <% else %>
<%= gettext("Never used") %> {gettext("Never used")}
<% end %> <% end %>
"""} """}
end end
defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do defp get_value_for_key(:range, pack, %{range: range}) do
assigns = %{range: range, pack: pack} assigns = %{range: range, pack: pack}
{staged, ~H"""
~H""" {render_slot(@range, @pack)}
<%= render_slot(@range, @pack) %> """
"""}
end end
defp get_value_for_key( defp get_value_for_key(
@ -223,7 +222,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
assigns = %{actions: actions, pack: pack} assigns = %{actions: actions, pack: pack}
~H""" ~H"""
<%= render_slot(@actions, @pack) %> {render_slot(@actions, @pack)}
""" """
end end
@ -244,7 +243,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
{container_name, {container_name,
~H""" ~H"""
<%= render_slot(@container_block, {@pack, @container}) %> {render_slot(@container_block, {@pack, @container})}
"""} """}
end end

View File

@ -99,7 +99,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
{pack.type.name, {pack.type.name,
~H""" ~H"""
<.link navigate={~p"/ammo/show/#{@pack}"} class="link"> <.link navigate={~p"/ammo/show/#{@pack}"} class="link">
<%= @pack.type.name %> {@pack.type.name}
</.link> </.link>
"""} """}
end end
@ -115,7 +115,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
assigns = %{actions: actions, shot_record: shot_record} assigns = %{actions: actions, shot_record: shot_record}
~H""" ~H"""
<%= render_slot(@actions, @shot_record) %> {render_slot(@actions, @shot_record)}
""" """
end end

View File

@ -20,6 +20,7 @@ defmodule CanneryWeb.Components.TableComponent do
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{ComparableDate, ComparableDateTime}
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
require Integer require Integer
@ -75,7 +76,7 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode: initial_sort_mode sort_mode: initial_sort_mode
) )
|> assign_new(:row_class, fn -> "bg-white" end) |> assign_new(:row_class, fn -> "bg-white" end)
|> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) |> assign_new(:alternate_row_class, fn -> "bg-zinc-200" end)
{:ok, socket} {:ok, socket}
end end
@ -110,7 +111,7 @@ defmodule CanneryWeb.Components.TableComponent do
end end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type)
when type in [Date, DateTime] do when type in [ComparableDate, ComparableDateTime, Date, DateTime] do
rows rows
|> Enum.sort_by( |> Enum.sort_by(
fn row -> fn row ->

View File

@ -1,4 +1,4 @@
<div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white"> <div id={@id} class="w-full overflow-x-auto border border-zinc-600 rounded-lg shadow-lg bg-white">
<table class="min-w-full table-auto text-center bg-white"> <table class="min-w-full table-auto text-center bg-white">
<thead class="border-b border-primary-600"> <thead class="border-b border-primary-600">
<tr> <tr>
@ -12,7 +12,7 @@
phx-target={@myself} phx-target={@myself}
> >
<i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
<span class={if @last_sort_key == key, do: "underline"}><%= label %></span> <span class={if @last_sort_key == key, do: "underline"}>{label}</span>
<%= if @last_sort_key == key do %> <%= if @last_sort_key == key do %>
<%= case @sort_mode do %> <%= case @sort_mode do %>
<% :asc -> %> <% :asc -> %>
@ -27,7 +27,7 @@
</th> </th>
<% else %> <% else %>
<th class={["p-2 cursor-not-allowed", column[:class]]}> <th class={["p-2 cursor-not-allowed", column[:class]]}>
<%= label %> {label}
</th> </th>
<% end %> <% end %>
<% end %> <% end %>
@ -41,9 +41,9 @@
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %> <%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %> <% {_custom_sort_value, value} -> %>
<%= value %> {value}
<% value -> %> <% value -> %>
<%= value %> {value}
<% end %> <% end %>
</td> </td>
</tr> </tr>

View File

@ -278,7 +278,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do
{type_name, {type_name,
~H""" ~H"""
<.link navigate={~p"/type/#{@id}"} class="link"> <.link navigate={~p"/type/#{@id}"} class="link">
<%= @name %> {@name}
</.link> </.link>
"""} """}
end end
@ -287,7 +287,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do
assigns = %{actions: actions, type: type} assigns = %{actions: actions, type: type}
~H""" ~H"""
<%= render_slot(@actions, @type) %> {render_slot(@actions, @type)}
""" """
end end

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title> <title>
<%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %> {dgettext("errors", "Error")} | {gettext("Cannery")}
</title> </title>
<link rel="stylesheet" href="/css/app.css" /> <link rel="stylesheet" href="/css/app.css" />
<script defer type="text/javascript" src="/js/app.js"> <script defer type="text/javascript" src="/js/app.js">
@ -19,13 +19,13 @@
<div class="pb-8 w-full flex flex-col justify-center items-center text-center"> <div class="pb-8 w-full flex flex-col justify-center items-center text-center">
<div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl"> <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl">
<h1 class="title text-primary-600 text-3xl"> <h1 class="title text-primary-600 text-3xl">
<%= @error_string %> {@error_string}
</h1> </h1>
<hr class="w-full hr" /> <hr class="w-full hr" />
<.link href={~p"/"} class="link title text-primary-600 text-lg"> <.link href={~p"/"} class="link title text-primary-600 text-lg">
<%= dgettext("errors", "Go back home") %> {dgettext("errors", "Go back home")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
defmodule CanneryWeb.ErrorJSON do defmodule CanneryWeb.ErrorJSON do
import CanneryWeb.Gettext use Gettext, backend: CanneryWeb.Gettext
def render(template, _assigns) do def render(template, _assigns) do
error_string = error_string =

View File

@ -4,9 +4,9 @@ defmodule CanneryWeb.UserAuth do
""" """
use CanneryWeb, :verified_routes use CanneryWeb, :verified_routes
use Gettext, backend: CanneryWeb.Gettext
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.User} alias Cannery.{Accounts, Accounts.User}
# Make the remember me cookie valid for 60 days. # Make the remember me cookie valid for 60 days.

View File

@ -1,7 +1,5 @@
defmodule CanneryWeb.UserConfirmationController do defmodule CanneryWeb.UserConfirmationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.Accounts alias Cannery.Accounts
def new(conn, _params) do def new(conn, _params) do

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl"> <h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Resend confirmation instructions") %> {dgettext("actions", "Resend confirmation instructions")}
</h1> </h1>
<.form <.form
@ -10,22 +10,22 @@
action={~p"/users/confirm"} action={~p"/users/confirm"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
<%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> {email_input(f, :email, required: true, class: "input input-primary col-span-2")}
<%= submit(dgettext("actions", "Resend confirmation instructions"), {submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
defmodule CanneryWeb.UserRegistrationController do defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.Invites} alias Cannery.{Accounts, Accounts.Invites}
alias Ecto.Changeset alias Ecto.Changeset

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl"> <h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</h1> </h1>
<.form <.form
@ -9,42 +9,47 @@
action={~p"/users/register"} action={~p"/users/register"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> <p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3">
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> {dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</p> </p>
<%= if @invite_token do %> <%= if @invite_token do %>
<%= hidden_input(f, :invite_token, value: @invite_token) %> {hidden_input(f, :invite_token, value: @invite_token)}
<% end %> <% end %>
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
<%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> {email_input(f, :email, required: true, class: "input input-primary col-span-2")}
<%= error_tag(f, :email, "col-span-3") %> {error_tag(f, :email, "col-span-3")}
<%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> {label(f, :password, gettext("Password"), class: "title text-lg text-primary-600")}
<%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> {password_input(f, :password, required: true, class: "input input-primary col-span-2")}
<%= error_tag(f, :password, "col-span-3") %> {error_tag(f, :password, "col-span-3")}
<%= label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600") %> {label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600")}
<%= select( {select(
f, f,
:locale, :locale,
[{gettext("English"), "en_US"}], [
{"English", "en_US"},
{"Deutsch", "de"},
{"Français", "fr"},
{"Español", "es"}
],
class: "input input-primary col-span-2" class: "input input-primary col-span-2"
) %> )}
<%= error_tag(f, :locale) %> {error_tag(f, :locale)}
<%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %> {submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3")}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</.link> </.link>
<.link href={~p"/users/reset_password"} class="btn btn-primary"> <.link href={~p"/users/reset_password"} class="btn btn-primary">
<%= dgettext("actions", "Forgot your password?") %> {dgettext("actions", "Forgot your password?")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -40,7 +40,7 @@ defmodule CanneryWeb.UserResetPasswordController do
# leaked token giving the user access to the account. # leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} -> {:ok, _socket} ->
conn conn
|> put_flash(:info, dgettext("prompts", "Password reset successfully.")) |> put_flash(:info, dgettext("prompts", "Password reset successfully."))
|> redirect(to: ~p"/users/log_in") |> redirect(to: ~p"/users/log_in")

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl"> <h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Reset password") %> {dgettext("actions", "Reset password")}
</h1> </h1>
<.form <.form
@ -9,36 +9,36 @@
action={~p"/users/reset_password/#{@token}"} action={~p"/users/reset_password/#{@token}"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> <p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3">
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> {dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</p> </p>
<%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> {label(f, :password, gettext("New password"), class: "title text-lg text-primary-600")}
<%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> {password_input(f, :password, required: true, class: "input input-primary col-span-2")}
<%= error_tag(f, :password, "col-span-3") %> {error_tag(f, :password, "col-span-3")}
<%= label(f, :password_confirmation, gettext("Confirm new password"), {label(f, :password_confirmation, gettext("Confirm new password"),
class: "title text-lg text-primary-600" class: "title text-lg text-primary-600"
) %> )}
<%= password_input(f, :password_confirmation, {password_input(f, :password_confirmation,
required: true, required: true,
class: "input input-primary col-span-2" class: "input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password_confirmation, "col-span-3") %> {error_tag(f, :password_confirmation, "col-span-3")}
<%= submit(dgettext("actions", "Reset password"), {submit(dgettext("actions", "Reset password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl"> <h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Forgot your password?") %> {dgettext("actions", "Forgot your password?")}
</h1> </h1>
<.form <.form
@ -10,22 +10,22 @@
action={~p"/users/reset_password"} action={~p"/users/reset_password"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
<%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> {email_input(f, :email, required: true, class: "input input-primary col-span-2")}
<%= submit(dgettext("actions", "Send instructions to reset password"), {submit(dgettext("actions", "Send instructions to reset password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl"> <h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Log in") %> {dgettext("actions", "Log in")}
</h1> </h1>
<.form <.form
@ -11,39 +11,39 @@
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<p :if={@error_message} class="alert alert-danger col-span-3"> <p :if={@error_message} class="alert alert-danger col-span-3">
<%= @error_message %> {@error_message}
</p> </p>
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
<%= email_input(f, :email, {email_input(f, :email,
autocomplete: :email, autocomplete: :email,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
required: true required: true
) %> )}
<%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> {label(f, :password, gettext("Password"), class: "title text-lg text-primary-600")}
<%= password_input(f, :password, {password_input(f, :password,
autocomplete: "current-password", autocomplete: "current-password",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
required: true required: true
) %> )}
<%= label(f, :remember_me, gettext("Keep me logged in for 60 days"), {label(f, :remember_me, gettext("Keep me logged in for 60 days"),
class: "title text-lg text-primary-600" class: "title text-lg text-primary-600"
) %> )}
<%= checkbox(f, :remember_me, class: "checkbox col-span-2") %> {checkbox(f, :remember_me, class: "checkbox col-span-2")}
<%= submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3") %> {submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3")}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "Register") %> {dgettext("actions", "Register")}
</.link> </.link>
<.link href={~p"/users/reset_password"} class="btn btn-primary"> <.link href={~p"/users/reset_password"} class="btn btn-primary">
<%= dgettext("actions", "Forgot your password?") %> {dgettext("actions", "Forgot your password?")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
defmodule CanneryWeb.UserSettingsController do defmodule CanneryWeb.UserSettingsController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.Accounts alias Cannery.Accounts
alias CanneryWeb.UserAuth alias CanneryWeb.UserAuth

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-3xl flex flex-col justify-center items-center text-right space-y-4"> <div class="flex flex-col justify-center items-center pb-8 mx-auto space-y-4 max-w-3xl text-right">
<h1 class="pb-4 title text-primary-600 text-2xl text-center"> <h1 class="pb-4 text-2xl text-center title text-primary-600">
<%= gettext("Settings") %> {gettext("Settings")}
</h1> </h1>
<hr class="hr" /> <hr class="hr" />
@ -9,40 +9,40 @@
:let={f} :let={f}
for={@email_changeset} for={@email_changeset}
action={~p"/users/settings"} action={~p"/users/settings"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"
> >
<h3 class="title text-primary-600 text-lg text-center col-span-3"> <h3 class="col-span-3 text-lg text-center title text-primary-600">
<%= dgettext("actions", "Change email") %> {dgettext("actions", "Change email")}
</h3> </h3>
<div <div
:if={@email_changeset.action && not @email_changeset.valid?()} :if={@email_changeset.action && not @email_changeset.valid?}
class="alert alert-danger col-span-3" class="col-span-3 alert alert-danger"
> >
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> {dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</div> </div>
<%= hidden_input(f, :action, name: "action", value: "update_email") %> {hidden_input(f, :action, name: "action", value: "update_email")}
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> {label(f, :email, gettext("Email"), class: "title text-lg text-primary-600")}
<%= email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2") %> {email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2")}
<%= error_tag(f, :email, "col-span-3") %> {error_tag(f, :email, "col-span-3")}
<%= label(f, :current_password, gettext("Current password"), {label(f, :current_password, gettext("Current password"),
for: "current_password_for_email", for: "current_password_for_email",
class: "mx-2 my-1 title text-lg text-primary-600" class: "mx-2 my-1 title text-lg text-primary-600"
) %> )}
<%= password_input(f, :current_password, {password_input(f, :current_password,
required: true, required: true,
name: "current_password", name: "current_password",
id: "current_password_for_email", id: "current_password_for_email",
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :current_password, "col-span-3") %> {error_tag(f, :current_password, "col-span-3")}
<%= submit(dgettext("actions", "Change email"), {submit(dgettext("actions", "Change email"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
@ -51,52 +51,52 @@
:let={f} :let={f}
for={@password_changeset} for={@password_changeset}
action={~p"/users/settings"} action={~p"/users/settings"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"
> >
<h3 class="title text-primary-600 text-lg text-center col-span-3"> <h3 class="col-span-3 text-lg text-center title text-primary-600">
<%= dgettext("actions", "Change password") %> {dgettext("actions", "Change password")}
</h3> </h3>
<div <div
:if={@password_changeset.action && not @password_changeset.valid?()} :if={@password_changeset.action && not @password_changeset.valid?}
class="alert alert-danger col-span-3" class="col-span-3 alert alert-danger"
> >
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> {dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</div> </div>
<%= hidden_input(f, :action, name: "action", value: "update_password") %> {hidden_input(f, :action, name: "action", value: "update_password")}
<%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> {label(f, :password, gettext("New password"), class: "title text-lg text-primary-600")}
<%= password_input(f, :password, {password_input(f, :password,
required: true, required: true,
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password, "col-span-3") %> {error_tag(f, :password, "col-span-3")}
<%= label(f, :password_confirmation, gettext("Confirm new password"), {label(f, :password_confirmation, gettext("Confirm new password"),
class: "title text-lg text-primary-600" class: "title text-lg text-primary-600"
) %> )}
<%= password_input(f, :password_confirmation, {password_input(f, :password_confirmation,
required: true, required: true,
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password_confirmation, "col-span-3") %> {error_tag(f, :password_confirmation, "col-span-3")}
<%= label(f, :current_password, gettext("Current password"), {label(f, :current_password, gettext("Current password"),
for: "current_password_for_password", for: "current_password_for_password",
class: "title text-lg text-primary-600" class: "title text-lg text-primary-600"
) %> )}
<%= password_input(f, :current_password, {password_input(f, :current_password,
required: true, required: true,
name: "current_password", name: "current_password",
id: "current_password_for_password", id: "current_password_for_password",
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :current_password, "col-span-3") %> {error_tag(f, :current_password, "col-span-3")}
<%= submit(dgettext("actions", "Change password"), {submit(dgettext("actions", "Change password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
@ -105,45 +105,45 @@
:let={f} :let={f}
for={@locale_changeset} for={@locale_changeset}
action={~p"/users/settings"} action={~p"/users/settings"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"
> >
<%= label(f, :locale, dgettext("actions", "Change Language"), {label(f, :locale, dgettext("actions", "Change Language"),
class: "title text-primary-600 text-lg text-center col-span-3" class: "title text-primary-600 text-lg text-center col-span-3"
) %> )}
<div <div
:if={@locale_changeset.action && not @locale_changeset.valid?()} :if={@locale_changeset.action && not @locale_changeset.valid?}
class="alert alert-danger col-span-3" class="col-span-3 alert alert-danger"
> >
<%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> {dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</div> </div>
<%= hidden_input(f, :action, name: "action", value: "update_locale") %> {hidden_input(f, :action, name: "action", value: "update_locale")}
<%= select( {select(
f, f,
:locale, :locale,
[ [
{gettext("English"), "en_US"}, {"English", "en_US"},
{gettext("German"), "de"}, {"Deutsch", "de"},
{gettext("French"), "fr"}, {"Français", "fr"},
{gettext("Spanish"), "es"} {"Español", "es"}
], ],
class: "mx-2 my-1 min-w-md input input-primary col-span-3" class: "my-1 min-w-20 input input-primary col-span-3"
) %> )}
<%= error_tag(f, :locale, "col-span-3") %> {error_tag(f, :locale, "col-span-3")}
<%= submit(dgettext("actions", "Change language"), {submit(dgettext("actions", "Change language"),
class: "whitespace-nowrap mx-auto btn btn-primary col-span-3", class: "whitespace-nowrap mx-auto btn btn-primary col-span-3",
data: [qa: dgettext("prompts", "Are you sure you want to change your language?")] data: [qa: dgettext("prompts", "Are you sure you want to change your language?")]
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank"> <.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank">
<%= dgettext("actions", "Export Data as JSON") %> {dgettext("actions", "Export Data as JSON")}
</.link> </.link>
<.link <.link
@ -152,7 +152,7 @@
class="mx-4 my-2 btn btn-alert" class="mx-4 my-2 btn btn-alert"
data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")} data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")}
> >
<%= dgettext("actions", "Delete User") %> {dgettext("actions", "Delete User")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ defmodule CanneryWeb.ErrorHelpers do
""" """
use PhoenixHTMLHelpers use PhoenixHTMLHelpers
import Phoenix.{Component, HTML.Form} import Phoenix.Component
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.{HTML.Form, LiveView.Rendered} alias Phoenix.{HTML.Form, LiveView.Rendered}
@ -19,10 +19,10 @@ defmodule CanneryWeb.ErrorHelpers do
~H""" ~H"""
<span <span
:for={error <- Keyword.get_values(@form.errors, @field)} :for={error <- Keyword.get_values(@form.errors, @field)}
:if={used_input?(@form[@field])}
class={["invalid-feedback", @extra_class]} class={["invalid-feedback", @extra_class]}
phx-feedback-for={input_name(@form, @field)}
> >
<%= translate_error(error) %> {translate_error(error)}
</span> </span>
""" """
end end

View File

@ -5,7 +5,7 @@ defmodule CanneryWeb.Gettext do
By using [Gettext](https://hexdocs.pm/gettext), By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example: your module gains a set of macros for translations, for example:
import CanneryWeb.Gettext use Gettext, backend: CanneryWeb.Gettext
# Simple translation # Simple translation
gettext("Here is the string to translate") gettext("Here is the string to translate")
@ -20,5 +20,5 @@ defmodule CanneryWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
""" """
use Gettext, otp_app: :cannery use Gettext.Backend, otp_app: :cannery
end end

View File

@ -1,6 +1,6 @@
<div class="flex flex-col justify-center items-center text-center space-y-8"> <div class="flex flex-col justify-center items-center text-center space-y-8">
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= @title %> {@title}
</h2> </h2>
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
@ -21,13 +21,13 @@
) )
} }
> >
<%= tag.name %> {tag.name}
<i class="fa-fw fa-sm fas fa-trash"></i> <i class="fa-fw fa-sm fas fa-trash"></i>
</.link> </.link>
<h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600"> <h2 :if={@container.tags |> Enum.empty?()} class="title text-xl text-primary-600">
<%= gettext("No tags") %> {gettext("No tags")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
</div> </div>
@ -43,15 +43,17 @@
phx-target={@myself} phx-target={@myself}
phx-submit="save" phx-submit="save"
> >
<%= select(f, :tag_id, tag_options(@tags, @container), {select(f, :tag_id, tag_options(@tags, @container),
class: "text-center col-span-2 input input-primary" class: "text-center col-span-2 input input-primary",
) %> id: "#{@id}-tag-select",
<%= error_tag(f, :tag_id, "col-span-3 text-center") %> phx_hook: "SlimSelect"
)}
{error_tag(f, :tag_id, "col-span-3 text-center")}
<%= submit(dgettext("actions", "Add"), {submit(dgettext("actions", "Add"),
class: "mx-auto btn btn-primary", class: "mx-auto btn btn-primary",
phx_disable_with: dgettext("prompts", "Adding...") phx_disable_with: dgettext("prompts", "Adding...")
) %> )}
</.form> </.form>
<% end %> <% end %>
</div> </div>

View File

@ -19,7 +19,7 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"container" => container_params}, socket) do def handle_event("validate", %{"container" => container_params}, socket) do
{:noreply, socket |> assign_changeset(container_params)} {:noreply, socket |> assign_changeset(container_params, :validate)}
end end
def handle_event( def handle_event(
@ -32,14 +32,9 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, container: container, current_user: user}} = socket, %{assigns: %{action: action, container: container, current_user: user}} = socket,
container_params container_params,
changeset_action \\ nil
) do ) do
changeset_action =
case action do
create when create in [:new, :clone] -> :insert
:edit -> :update
end
changeset = changeset =
case action do case action do
create when create in [:new, :clone] -> create when create in [:new, :clone] ->
@ -50,9 +45,13 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
end end
changeset = changeset =
case changeset |> Changeset.apply_action(changeset_action) do if changeset_action do
{:ok, _data} -> changeset case changeset |> Changeset.apply_action(changeset_action) do
{:error, changeset} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset
end
else
changeset
end end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)

View File

@ -1,6 +1,6 @@
<div> <div>
<h2 class="mb-8 text-center title text-xl text-primary-600"> <h2 class="mb-8 text-center title text-xl text-primary-600">
<%= @title %> {@title}
</h2> </h2>
<.form <.form
:let={f} :let={f}
@ -12,49 +12,53 @@
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?()} :if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> {changeset_errors(@changeset)}
</div> </div>
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> {label(f, :name, gettext("Name"), class: "title text-lg text-primary-600")}
<%= text_input(f, :name, {text_input(f, :name,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("My cool ammo can"), maxlength: 255,
maxlength: 255 phx_debounce: 300,
) %> placeholder: gettext("My cool ammo can")
<%= error_tag(f, :name, "col-span-3 text-center") %> )}
{error_tag(f, :name, "col-span-3 text-center")}
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> {label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600")}
<%= textarea(f, :desc, {textarea(f, :desc,
class: "input input-primary col-span-2",
id: "container-form-desc", id: "container-form-desc",
class: "input input-primary col-span-2", phx_debounce: 300,
placeholder: gettext("Metal ammo can with the anime girl sticker"), phx_update: "ignore",
phx_update: "ignore" placeholder: gettext("Metal ammo can with the anime girl sticker")
) %> )}
<%= error_tag(f, :desc, "col-span-3 text-center") %> {error_tag(f, :desc, "col-span-3 text-center")}
<%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> {label(f, :type, gettext("Type"), class: "title text-lg text-primary-600")}
<%= text_input(f, :type, {text_input(f, :type,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
placeholder: gettext("Magazine, Clip, Ammo Box, etc"), maxlength: 255,
maxlength: 255 phx_debounce: 300,
) %> placeholder: gettext("Magazine, Clip, Ammo Box, etc")
<%= error_tag(f, :type, "col-span-3 text-center") %> )}
{error_tag(f, :type, "col-span-3 text-center")}
<%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> {label(f, :location, gettext("Location"), class: "title text-lg text-primary-600")}
<%= textarea(f, :location, {textarea(f, :location,
class: "input input-primary col-span-2",
id: "container-form-location", id: "container-form-location",
class: "input input-primary col-span-2", phx_debounce: 300,
placeholder: gettext("On the bookshelf"), phx_update: "ignore",
phx_update: "ignore" placeholder: gettext("On the bookshelf")
) %> )}
<%= error_tag(f, :location, "col-span-3 text-center") %> {error_tag(f, :location, "col-span-3 text-center")}
<%= submit(dgettext("actions", "Save"), {submit(dgettext("actions", "Save"),
class: "mx-auto btn btn-primary col-span-3", class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "Saving...") phx_disable_with: dgettext("prompts", "Saving...")
) %> )}
</.form> </.form>
</div> </div>

View File

@ -112,6 +112,20 @@ defmodule CanneryWeb.ContainerLive.Index do
{:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")} {:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")}
end end
def handle_event(
"toggle_staged",
%{"container_id" => id},
%{assigns: %{current_user: current_user}} = socket
) do
container = Containers.get_container!(id, current_user)
{:ok, _container} =
container
|> Containers.update_container(current_user, %{"staged" => !container.staged})
{:noreply, socket |> display_containers()}
end
defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do
socket |> assign(:containers, Containers.list_containers(current_user, search: search)) socket |> assign(:containers, Containers.list_containers(current_user, search: search))
end end

View File

@ -1,51 +1,51 @@
<div class="flex flex-col space-y-8 justify-center items-center"> <div class="flex flex-col justify-center items-center space-y-8">
<h1 class="title text-2xl title-primary-500"> <h1 class="text-2xl title title-primary-500">
<%= gettext("Containers") %> {gettext("Containers")}
</h1> </h1>
<%= if @containers |> Enum.empty?() and @search |> is_nil() do %> <%= if @containers |> Enum.empty?() and @search |> is_nil() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="text-xl title text-primary-600">
<%= gettext("No containers") %> {gettext("No containers")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
<.link patch={~p"/containers/new"} class="btn btn-primary"> <.link patch={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "Add your first container!") %> {dgettext("actions", "Add your first container!")}
</.link> </.link>
<% else %> <% else %>
<.link patch={~p"/containers/new"} class="btn btn-primary"> <.link patch={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "New Container") %> {dgettext("actions", "New Container")}
</.link> </.link>
<div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> <div class="flex flex-col flex-wrap justify-center items-center space-y-4 w-full sm:flex-row sm:space-y-0 sm:space-x-4">
<.form <.form
:let={f} :let={f}
for={%{}} for={%{}}
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow flex items-center" class="flex items-center grow"
> >
<%= text_input(f, :search_term, {text_input(f, :search_term,
class: "grow input input-primary", class: "grow input input-primary",
value: @search,
role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("Search containers") placeholder: gettext("Search containers"),
) %> role: "search",
value: @search
)}
</.form> </.form>
<.toggle_button action="toggle_table" value={@view_table}> <.toggle_button action="toggle_table" value={@view_table}>
<span class="title text-lg text-primary-600"> <span class="text-lg title text-primary-600">
<%= gettext("View as table") %> {gettext("View as table")}
</span> </span>
</.toggle_button> </.toggle_button>
</div> </div>
<%= if @containers |> Enum.empty?() do %> <%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="text-xl title text-primary-600">
<%= gettext("No containers") %> {gettext("No containers")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
<% else %> <% else %>
<%= if @view_table do %> <%= if @view_table do %>
@ -56,6 +56,20 @@
containers={@containers} containers={@containers}
current_user={@current_user} current_user={@current_user}
> >
<:range :let={container}>
<div class="flex flex-wrap justify-center items-center px-4 py-2 h-full min-w-20">
<button
type="button"
class="mx-2 my-1 text-sm btn btn-primary"
phx-click="toggle_staged"
phx-value-container_id={container.id}
>
{if container.staged,
do: dgettext("actions", "Unstage"),
else: dgettext("actions", "Stage")}
</button>
</div>
</:range>
<:tag_actions :let={container}> <:tag_actions :let={container}>
<div class="mx-4 my-2"> <div class="mx-4 my-2">
<.link <.link
@ -109,7 +123,7 @@
</:actions> </:actions>
</.live_component> </.live_component>
<% else %> <% else %>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch"> <div class="flex flex-row flex-wrap justify-center items-stretch w-full">
<.container_card <.container_card
:for={container <- @containers} :for={container <- @containers}
container={container} container={container}

View File

@ -78,6 +78,18 @@ defmodule CanneryWeb.ContainerLive.Show do
{:noreply, socket} {:noreply, socket}
end end
def handle_event(
"toggle_staged",
_params,
%{assigns: %{container: container, current_user: current_user}} = socket
) do
{:ok, _container} =
container
|> Containers.update_container(current_user, %{"staged" => !container.staged})
{:noreply, socket |> render_container()}
end
def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table) |> render_container()} {:noreply, socket |> assign(:view_table, !view_table) |> render_container()}
end end

View File

@ -1,34 +1,34 @@
<div class="space-y-4 flex flex-col justify-center items-center"> <div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-2xl title-primary-500"> <h1 class="text-2xl title title-primary-500">
<%= @container.name %> {@container.name}
</h1> </h1>
<span :if={@container.desc} class="rounded-lg title text-lg"> <span :if={@container.desc} class="text-lg rounded-lg title">
<%= gettext("Description:") %> {gettext("Description:")}
<%= @container.desc %> {@container.desc}
</span> </span>
<span class="rounded-lg title text-lg"> <span class="text-lg rounded-lg title">
<%= gettext("Type:") %> {gettext("Type:")}
<%= @container.type %> {@container.type}
</span> </span>
<span :if={@container.location} class="rounded-lg title text-lg"> <span :if={@container.location} class="text-lg rounded-lg title">
<%= gettext("Location:") %> {gettext("Location:")}
<%= @container.location %> {@container.location}
</span> </span>
<span class="rounded-lg title text-lg"> <span class="text-lg rounded-lg title">
<%= gettext("Packs:") %> {gettext("Packs:")}
<%= @packs_count %> {@packs_count}
</span> </span>
<span class="rounded-lg title text-lg"> <span class="text-lg rounded-lg title">
<%= gettext("Rounds:") %> {gettext("Rounds:")}
<%= @round_count %> {@round_count}
</span> </span>
<div class="flex space-x-4 justify-center items-center text-primary-600"> <div class="flex justify-center items-center space-x-4 text-primary-600">
<.link <.link
patch={~p"/container/edit/#{@container}"} patch={~p"/container/edit/#{@container}"}
class="text-primary-600 link" class="text-primary-600 link"
@ -52,17 +52,25 @@
</.link> </.link>
</div> </div>
<div class="flex flex-wrap justify-center items-center text-primary-600">
<button type="button" class="mx-4 my-2 btn btn-primary" phx-click="toggle_staged">
{if @container.staged,
do: dgettext("actions", "Unstage from range"),
else: dgettext("actions", "Stage for range")}
</button>
</div>
<hr class="mb-4 hr" /> <hr class="mb-4 hr" />
<%= if @container.tags |> Enum.empty?() do %> <%= if @container.tags |> Enum.empty?() do %>
<div class="flex flex-row justify-center items-center space-x-4"> <div class="flex flex-row justify-center items-center space-x-4">
<h2 class="title text-lg text-primary-600"> <h2 class="text-lg title text-primary-600">
<%= gettext("No tags for this container") %> {gettext("No tags for this container")}
<%= display_emoji("😔") %> {display_emoji("😔")}
</h2> </h2>
<.link patch={~p"/container/edit_tags/#{@container}"} class="btn btn-primary"> <.link patch={~p"/container/edit_tags/#{@container}"} class="btn btn-primary">
<%= dgettext("actions", "Why not add one?") %> {dgettext("actions", "Why not add one?")}
</.link> </.link>
</div> </div>
<% else %> <% else %>
@ -88,9 +96,9 @@
phx-submit="change_class" phx-submit="change_class"
class="flex items-center" class="flex items-center"
> >
<%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> {label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center")}
<%= select( {select(
f, f,
:class, :class,
[ [
@ -99,22 +107,22 @@
{gettext("Shotgun"), :shotgun}, {gettext("Shotgun"), :shotgun},
{gettext("Pistol"), :pistol} {gettext("Pistol"), :pistol}
], ],
class: "mx-2 my-1 min-w-md input input-primary", class: "mx-2 my-1 min-w-20 input input-primary",
value: @class value: @class
) %> )}
</.form> </.form>
<.toggle_button action="toggle_table" value={@view_table}> <.toggle_button action="toggle_table" value={@view_table}>
<span class="title text-lg text-primary-600"> <span class="text-lg title text-primary-600">
<%= gettext("View as table") %> {gettext("View as table")}
</span> </span>
</.toggle_button> </.toggle_button>
</div> </div>
<div class="w-full p-4"> <div class="p-4 w-full">
<%= if @packs |> Enum.empty?() do %> <%= if @packs |> Enum.empty?() do %>
<h2 class="mx-4 title text-lg text-primary-600 text-center"> <h2 class="mx-4 text-lg text-center title text-primary-600">
<%= gettext("No ammo in this container") %> {gettext("No ammo in this container")}
</h2> </h2>
<% else %> <% else %>
<%= if @view_table do %> <%= if @view_table do %>
@ -127,11 +135,11 @@
> >
<:type :let={%{name: type_name} = type}> <:type :let={%{name: type_name} = type}>
<.link navigate={~p"/type/#{type}"} class="link"> <.link navigate={~p"/type/#{type}"} class="link">
<%= type_name %> {type_name}
</.link> </.link>
</:type> </:type>
<:actions :let={%{count: pack_count} = pack}> <:actions :let={%{count: pack_count} = pack}>
<div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> <div class="flex justify-center items-center px-4 py-2 space-x-4 h-full">
<.link <.link
navigate={~p"/ammo/show/#{pack}"} navigate={~p"/ammo/show/#{pack}"}
class="text-primary-600 link" class="text-primary-600 link"

View File

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

View File

@ -19,7 +19,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"invite" => invite_params}, socket) do def handle_event("validate", %{"invite" => invite_params}, socket) do
{:noreply, socket |> assign_changeset(invite_params)} {:noreply, socket |> assign_changeset(invite_params, :validate)}
end end
def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do
@ -28,14 +28,9 @@ defmodule CanneryWeb.InviteLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, current_user: user, invite: invite}} = socket, %{assigns: %{action: action, current_user: user, invite: invite}} = socket,
invite_params invite_params,
changeset_action \\ nil
) do ) do
changeset_action =
case action do
:new -> :insert
:edit -> :update
end
changeset = changeset =
case action do case action do
:new -> Invite.create_changeset(user, "example_token", invite_params) :new -> Invite.create_changeset(user, "example_token", invite_params)
@ -43,9 +38,13 @@ defmodule CanneryWeb.InviteLive.FormComponent do
end end
changeset = changeset =
case changeset |> Changeset.apply_action(changeset_action) do if changeset_action do
{:ok, _data} -> changeset case changeset |> Changeset.apply_action(changeset_action) do
{:error, changeset} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset
end
else
changeset
end end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)

View File

@ -1,6 +1,6 @@
<div> <div>
<h2 class="mb-8 text-center title text-xl text-primary-600"> <h2 class="mb-8 text-center title text-xl text-primary-600">
<%= @title %> {@title}
</h2> </h2>
<.form <.form
:let={f} :let={f}
@ -12,29 +12,32 @@
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?()} :if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> {changeset_errors(@changeset)}
</div> </div>
<%= label(f, :name, gettext("Name"), {label(f, :name, gettext("Name"),
class: "title text-lg text-primary-600", class: "title text-lg text-primary-600",
maxlength: 255 maxlength: 255
) %> )}
<%= text_input(f, :name, class: "input input-primary col-span-2") %> {text_input(f, :name,
<%= error_tag(f, :name, "col-span-3") %> class: "input input-primary col-span-2",
phx_debounce: 300
)}
{error_tag(f, :name, "col-span-3")}
<%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600") %> {label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600")}
<%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> {number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2")}
<%= error_tag(f, :uses_left, "col-span-3") %> {error_tag(f, :uses_left, "col-span-3")}
<span class="col-span-3 text-primary-400 italic text-center"> <span class="col-span-3 text-primary-400 italic text-center">
<%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %> {gettext(~s/Leave "Uses left" blank to make invite unlimited/)}
</span> </span>
<%= submit(dgettext("actions", "Save"), {submit(dgettext("actions", "Save"),
class: "mx-auto btn btn-primary col-span-3", class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "Saving...") phx_disable_with: dgettext("prompts", "Saving...")
) %> )}
</.form> </.form>
</div> </div>

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