Compare commits

..

No commits in common. "dev" and "dev" have entirely different histories.
dev ... dev

247 changed files with 28713 additions and 11471 deletions

View File

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

View File

@ -1,6 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
import_deps: [:ecto, :phoenix],
inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

9
.gitignore vendored
View File

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

View File

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.1
nodejs 23.10.0
elixir 1.14.1-otp-25
erlang 25.1.2
nodejs 18.9.1

View File

@ -1,71 +1,7 @@
# v0.9.14
- Update deps
- Fix wrapping issues with search bars
- Improve accuracy of timestamps
# v0.9.13
- Add button to resend email verification email
- Move staging to container, rather than ammo
- Add date restriction dropdown to range page
- Fix dates not rendering properly in table
- Update deps
# v0.9.12
- Allow filtering ammo types when creating new packs
- Add SlimSelect to select elements with user content
- Fix registration page not offering all translations
- Update deps
# v0.9.11
- Fix an issue with emails not being able to be sent for real this time
- Fix some dropdowns not filling in the correct data
- Add debounces to more fields
- Update deps
# v0.9.10
- Fix issue with logger failing on oban exceptions
- Fix an issue with emails not being able to be sent
- Update deps
# v0.9.9
- Actually fix bar graph
# v0.9.8
- Make bar graph ignore empty days
- Update dependencies
# v0.9.7
- Fix margin on bottom of page
- Use bar graph instead of line graph
- Improve login page autocomplete behavior
# v0.9.6
- Make ammo packs in containers directly navigable in table view
- Update dependencies
# v0.9.5
- Update dependencies
# v0.9.4
- Code quality fixes
- Fix error/404 pages not rendering properly
- Update dependencies
- Fix Range page title
# v0.9.3
- Update dependencies
- Add pack lot number to search
- Improve tests
- Change invite path slightly
- Disable arm builds since ci fails to build
# v0.9.2
- Add lot number to packs
- Don't show price paid and lot number columns when displaying packs if not used
- Fix additional shotgun fields not being exportable
- Fixes duplicate chamber size column for ammo types
- Hide bullet type field when editing/creating shotgun ammo types
- Fix ammo type creation not displaying all the necessary fields on first load
# v0.9.1
- Rename ammo type's "type" to "class" to avoid confusion

View File

@ -10,9 +10,6 @@ status](https://weblate.bubbletea.dev/widgets/cannery/-/287x66-black.png)](https
If you're multilingual, this project can use your translations! Visit
[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
@ -130,7 +127,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:
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start.
- `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set!

View File

@ -1,4 +1,4 @@
FROM elixir:1.18.3-otp-27-alpine AS build
FROM elixir:1.14.1-alpine AS build
# install build dependencies
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
# install hex + rebar
RUN mix local.rebar --force && \
mix local.hex --force
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV
ENV MIX_ENV=prod
@ -25,25 +25,24 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
COPY lib lib
COPY priv priv
COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix do phx.digest, gettext.extract
# compile and build release
# uncomment COPY if rel/ exists
# COPY rel rel
RUN mix do assets.deploy, compile, release
RUN mix do compile, release
# prepare release image
FROM alpine:latest AS app
RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs
apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs
WORKDIR /app
RUN chown nobody:nobody /app
ENV MIX_ENV=prod
USER nobody:nobody
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`.
- `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
with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start.
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US`
@ -94,7 +94,6 @@ license can be found at
# Links
- [Website](https://cannery.app): Project website
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please

5
assets/.babelrc Normal file
View File

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

154
assets/css/app.scss Normal file
View File

@ -0,0 +1,154 @@
@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

@ -0,0 +1,57 @@
@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;
}
}

View File

@ -1,266 +0,0 @@
@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,3 +1,7 @@
// 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`
// to get started and then uncomment the line below.
// import "./user_socket.js"
@ -20,16 +24,16 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart'
import Date from './date'
import 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 liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, ShotLogChart, SlimSelect }
hooks: { Date, DateTime, MaintainAttrs, ShotLogChart }
})
// Show progress bar on live navigation and form submits

View File

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

View File

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

View File

@ -0,0 +1,11 @@
// maintain user adjusted attributes, like textbox length on phoenix liveview
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
export default {
attrs () {
const attrs = this.el.getAttribute('data-attrs')
if (attrs) { return attrs.split(', ') } else { return [] }
},
beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) },
updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) }
}

View File

@ -1,12 +1,13 @@
import Chart from 'chart.js/auto'
import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js'
import 'chartjs-adapter-date-fns'
Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale)
export default {
initalizeChart (el) {
const data = JSON.parse(el.dataset.chartData)
this.el.chart = new Chart(el, {
type: 'bar',
type: 'line',
data: {
datasets: [{
label: el.dataset.label,
@ -50,17 +51,13 @@ export default {
stacked: true,
grace: '15%',
ticks: {
padding: 15,
precision: 0
padding: 15
}
},
x: {
type: 'timeseries',
type: 'time',
time: {
unit: 'day'
},
ticks: {
source: 'data'
}
}
},

View File

@ -1,28 +0,0 @@
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)
}
}

23220
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,49 @@
{
"repository": {},
"description": " ",
"license": "MIT",
"engines": {
"node": "v23.10.0"
"node": "v18.9.1",
"npm": "8.19.1"
},
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin",
"format": "standard --fix",
"test": "standard"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"chart.js": "^4.4.8",
"@fortawesome/fontawesome-free": "^6.3.0",
"chart.js": "^4.2.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"slim-select": "^2.11.0",
"topbar": "^3.0.0"
"date-fns": "^2.29.3",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^2.0.1"
},
"devDependencies": {
"npm-check-updates": "^17.1.16",
"standard": "^17.1.2"
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.7.5",
"npm-check-updates": "^16.7.12",
"postcss": "^8.4.21",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.0.1",
"sass": "^1.59.3",
"sass-loader": "^13.2.1",
"standard": "^17.0.0",
"tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.7",
"webpack": "^5.76.2",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.1"
}
}

7
assets/postcss.config.js Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

43
assets/tailwind.config.js Normal file
View File

@ -0,0 +1,43 @@
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: []
}

57
assets/webpack.config.js Normal file
View File

@ -0,0 +1,57 @@
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,31 +8,26 @@
import Config
config :cannery,
env: :dev,
ecto_repos: [Cannery.Repo]
ecto_repos: [Cannery.Repo],
generators: [binary_id: true]
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint
config :cannery, CanneryWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [
formats: [html: CanneryWeb.ErrorHTML, json: CanneryWeb.ErrorJSON],
layout: false
],
render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false],
pubsub_server: Cannery.PubSub,
live_view: [signing_salt: "zOLgd3lr"]
config :cannery, Cannery.Application, automigrate: false
config :cannery, :generators,
binary_id: true,
migration: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111",
timestamp_type: :utc_datetime_usec
binary_id: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111"
# Configures the mailer
#
@ -56,25 +51,14 @@ config :cannery, Oban,
queues: [default: 10, mailers: 20]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.17.11",
cannery: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __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__)
]
# config :esbuild,
# version: "0.14.0",
# default: [
# args:
# ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
# cd: Path.expand("../assets", __DIR__),
# env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
# ]
# Configures Elixir's Logger
config :logger, :console,

View File

@ -2,7 +2,6 @@ import Config
# Configure your database
config :cannery, Cannery.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
@ -13,14 +12,21 @@ config :cannery, Cannery.Repo,
# watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources.
config :cannery, CanneryWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
watchers: [
esbuild: {Esbuild, :install_and_run, [:cannery, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:cannery, ~w(--watch)]}
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
# esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
cd: Path.expand("../assets", __DIR__)
]
]
# ## SSL Support
@ -51,9 +57,10 @@ config :cannery, CanneryWeb.Endpoint,
config :cannery, CanneryWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/cannery_web/*/.*(ex)$"
~r"lib/cannery_web/(live|views)/.*(ex)$",
~r"lib/cannery_web/templates/.*(eex)$"
]
]
@ -67,9 +74,3 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
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,8 +14,6 @@ config :cannery, CanneryWeb.Endpoint, cache_static_manifest: "priv/static/cache_
# Do not print debug messages in production
config :logger, level: :info
config :cannery, env: :prod
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key

View File

@ -7,22 +7,12 @@ import Config
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# 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
# Start the phoenix server if environment is set and running in a release
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :cannery, CanneryWeb.Endpoint, server: true
end
config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
config :cannery, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
# Set default locale
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
@ -78,7 +68,7 @@ if config_env() == :prod do
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: priv/random.sh
You can generate one by calling: mix phx.gen.secret
"""
config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base

View File

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

View File

@ -6,35 +6,4 @@ defmodule Cannery do
Contexts are also responsible for managing your data, regardless
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

View File

@ -3,9 +3,10 @@ defmodule Cannery.Accounts do
The Accounts context.
"""
use Cannery, :context
alias Cannery.Mailer
alias Cannery.Accounts.{Invite, Invites, UserToken}
import Ecto.Query, warn: false
alias Cannery.{Mailer, Repo}
alias Cannery.Accounts.{Invite, Invites, User, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job
## Database getters
@ -218,7 +219,7 @@ defmodule Cannery.Accounts do
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok
else
_error_tuple -> :error
@ -373,8 +374,8 @@ defmodule Cannery.Accounts do
@doc """
Deletes the signed token with the given context.
"""
@spec delete_user_session_token(token :: String.t()) :: :ok
def delete_user_session_token(token) do
@spec delete_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok
end
@ -403,15 +404,15 @@ defmodule Cannery.Accounts do
## Examples
iex> admin?(%User{role: :admin})
iex> is_admin?(%User{role: :admin})
true
iex> admin?(%User{})
iex> is_admin?(%User{})
false
"""
@spec admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do
@spec is_admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
end
@ -420,16 +421,16 @@ defmodule Cannery.Accounts do
## Examples
iex> already_admin?(%User{role: :admin})
iex> is_already_admin?(%User{role: :admin})
true
iex> already_admin?(%User{})
iex> is_already_admin?(%User{})
false
"""
@spec already_admin?(User.t() | nil) :: boolean()
def already_admin?(%User{role: :admin}), do: true
def already_admin?(_invalid_user), do: false
@spec is_already_admin?(User.t() | nil) :: boolean()
def is_already_admin?(%User{role: :admin}), do: true
def is_already_admin?(_invalid_user), do: false
## Confirmation

View File

@ -3,15 +3,14 @@ defmodule Cannery.Email do
Emails that can be sent using Swoosh.
You can find the base email templates at
`lib/cannery_web/components/layouts/email_html.html.heex` for html emails and
`lib/cannery_web/components/layouts/email_text.txt.eex` for text emails.
`lib/cannery_web/templates/layout/email.html.heex` for html emails and
`lib/cannery_web/templates/layout/email.txt.heex` for text emails.
"""
use Gettext, backend: CanneryWeb.Gettext
import Swoosh.Email
import Phoenix.Template
use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email}
import CanneryWeb.Gettext
alias Cannery.Accounts.User
alias CanneryWeb.{EmailHTML, Layouts}
alias CanneryWeb.EmailView
@typedoc """
Represents an HTML and text body email that can be sent
@ -29,33 +28,21 @@ defmodule Cannery.Email do
def generate_email("welcome", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Confirm your Cannery account"))
|> html_email(:confirm_email_html, %{user: user, url: url})
|> text_email(:confirm_email_text, %{user: user, url: url})
|> render_body("confirm_email.html", %{user: user, url: url})
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
end
def generate_email("reset_password", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Reset your Cannery password"))
|> html_email(:reset_password_html, %{user: user, url: url})
|> text_email(:reset_password_text, %{user: user, url: url})
|> render_body("reset_password.html", %{user: user, url: url})
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
end
def generate_email("update_email", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Update your Cannery email"))
|> html_email(:update_email_html, %{user: user, url: url})
|> text_email(:update_email_text, %{user: user, url: url})
end
defp html_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: heex)
email |> html_body(html)
end
defp text_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: heex)
email |> text_body(text)
|> render_body("update_email.html", %{user: user, url: url})
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url}))
end
end

View File

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

View File

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

View File

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

View File

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

View File

@ -3,56 +3,43 @@ defmodule Cannery.ActivityLog do
The ActivityLog context.
"""
use Cannery, :context
alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type}
@type list_shot_records_option ::
{:search, String.t() | nil}
| {:class, Type.class() | :all | nil}
| {:start_date, String.t() | nil}
| {:end_date, String.t() | nil}
| {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()]
import Ecto.Query, warn: false
alias Cannery.Ammo.{Pack, Type}
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo}
alias Ecto.{Multi, Queryable}
@doc """
Returns the list of shot_records.
## Examples
iex> list_shot_records(%User{id: 123})
iex> list_shot_records(:all, %User{id: 123})
[%ShotRecord{}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool")
iex> list_shot_records("cool", :all, %User{id: 123})
[%ShotRecord{notes: "My cool shot record"}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool", class: :rifle)
iex> list_shot_records("cool", :rifle, %User{id: 123})
[%ShotRecord{notes: "Shot some rifle rounds"}, ...]
iex> list_shot_records(%User{id: 123}, pack_id: 456)
[%ShotRecord{pack_id: 456}, ...]
"""
@spec list_shot_records(User.t()) :: [ShotRecord.t()]
@spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()]
def list_shot_records(%User{id: user_id}, opts \\ []) do
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
as: :p,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
left_join: t in Type,
as: :t,
on: p.type_id == t.id,
on: t.user_id == ^user_id,
where: sr.user_id == ^user_id,
distinct: sr.id
@spec list_shot_records(Type.class() | :all, User.t()) :: [ShotRecord.t()]
@spec list_shot_records(search :: nil | String.t(), Type.class() | :all, User.t()) ::
[ShotRecord.t()]
def list_shot_records(search \\ nil, type, %{id: user_id}) do
from(sg in ShotRecord,
as: :sg,
left_join: ag in Pack,
as: :ag,
on: sg.pack_id == ag.id,
left_join: at in Type,
as: :at,
on: ag.type_id == at.id,
where: sg.user_id == ^user_id,
distinct: sg.id
)
|> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> list_shot_records_start_date(Keyword.get(opts, :start_date))
|> list_shot_records_end_date(Keyword.get(opts, :end_date))
|> list_shot_records_search(search)
|> list_shot_records_filter_type(type)
|> Repo.all()
end
@ -65,58 +52,45 @@ defmodule Cannery.ActivityLog do
query
|> where(
[sr: sr, p: p, t: t],
[sg: sg, ag: ag, at: at],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
sr.search,
sg.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
p.search,
ag.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
at.search,
^trimmed_search
)
)
|> order_by([sr: sr], {
|> order_by([sg: sg], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sr.search,
sg.search,
^trimmed_search
)
})
end
@spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t()
defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun],
do: query |> where([t: t], t.class == ^class)
@spec list_shot_records_filter_type(Queryable.t(), Type.class() | :all) ::
Queryable.t()
defp list_shot_records_filter_type(query, :rifle),
do: query |> where([at: at], at.class == :rifle)
defp list_shot_records_class(query, _all), do: query
defp list_shot_records_filter_type(query, :pistol),
do: query |> where([at: at], at.class == :pistol)
@spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(),
do: query |> where([sr: sr], sr.pack_id == ^pack_id)
defp list_shot_records_filter_type(query, :shotgun),
do: query |> where([at: at], at.class == :shotgun)
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
defp list_shot_records_filter_type(query, _all), do: query
@doc """
Returns a count of shot records.
@ -130,13 +104,25 @@ defmodule Cannery.ActivityLog do
@spec get_shot_record_count!(User.t()) :: integer()
def get_shot_record_count!(%User{id: user_id}) do
Repo.one(
from sr in ShotRecord,
where: sr.user_id == ^user_id,
select: count(sr.id),
from sg in ShotRecord,
where: sg.user_id == ^user_id,
select: count(sg.id),
distinct: true
) || 0
end
@spec list_shot_records_for_pack(Pack.t(), User.t()) :: [ShotRecord.t()]
def list_shot_records_for_pack(
%Pack{id: pack_id, user_id: user_id},
%User{id: user_id}
) do
Repo.all(
from sg in ShotRecord,
where: sg.pack_id == ^pack_id,
where: sg.user_id == ^user_id
)
end
@doc """
Gets a single shot_record.
@ -154,10 +140,10 @@ defmodule Cannery.ActivityLog do
@spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t()
def get_shot_record!(id, %User{id: user_id}) do
Repo.one!(
from sr in ShotRecord,
where: sr.id == ^id,
where: sr.user_id == ^user_id,
order_by: sr.date
from sg in ShotRecord,
where: sg.id == ^id,
where: sg.user_id == ^user_id,
order_by: sg.date
)
end
@ -186,9 +172,9 @@ defmodule Cannery.ActivityLog do
fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
pack =
Repo.one(
from p in Pack,
where: p.id == ^pack_id,
where: p.user_id == ^user_id
from ag in Pack,
where: ag.id == ^pack_id,
where: ag.user_id == ^user_id
)
{:ok, pack}
@ -235,7 +221,7 @@ defmodule Cannery.ActivityLog do
|> Multi.run(
:pack,
fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
{:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)}
end
)
|> Multi.update(
@ -280,7 +266,7 @@ defmodule Cannery.ActivityLog do
|> Multi.run(
:pack,
fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
{:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)}
end
)
|> Multi.update(
@ -301,6 +287,36 @@ defmodule Cannery.ActivityLog do
end
end
@doc """
Returns the number of shot rounds for a pack
"""
@spec get_used_count(Pack.t(), User.t()) :: non_neg_integer()
def get_used_count(%Pack{id: pack_id} = pack, user) do
[pack]
|> get_used_counts(user)
|> Map.get(pack_id, 0)
end
@doc """
Returns the number of shot rounds for multiple packs
"""
@spec get_used_counts([Pack.t()], User.t()) ::
%{optional(Pack.id()) => non_neg_integer()}
def get_used_counts(packs, %User{id: user_id}) do
pack_ids =
packs
|> Enum.map(fn %{id: pack_id} -> pack_id end)
Repo.all(
from sg in ShotRecord,
where: sg.pack_id in ^pack_ids,
where: sg.user_id == ^user_id,
group_by: sg.pack_id,
select: {sg.pack_id, sum(sg.count)}
)
|> Map.new()
end
@doc """
Returns the last entered shot record date for a pack
"""
@ -321,18 +337,15 @@ defmodule Cannery.ActivityLog do
|> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end)
Repo.all(
from sr in ShotRecord,
where: sr.pack_id in ^pack_ids,
where: sr.user_id == ^user_id,
group_by: sr.pack_id,
select: {sr.pack_id, max(sr.date)}
from sg in ShotRecord,
where: sg.pack_id in ^pack_ids,
where: sg.user_id == ^user_id,
group_by: sg.pack_id,
select: {sg.pack_id, max(sg.date)}
)
|> Map.new()
end
@type get_used_count_option :: {:pack_id, Pack.id() | nil} | {:type_id, Type.id() | nil}
@type get_used_count_options :: [get_used_count_option()]
@doc """
Gets the total number of rounds shot for a type
@ -340,116 +353,45 @@ defmodule Cannery.ActivityLog do
## Examples
iex> get_used_count(%User{id: 123}, type_id: 123)
iex> get_used_count_for_type(123, %User{id: 123})
35
iex> get_used_count(%User{id: 123}, pack_id: 456)
50
iex> get_used_count_for_type(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer()
def get_used_count(%User{id: user_id}, opts) do
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
as: :p,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil()),
select: sum(sr.count),
distinct: true
)
|> get_used_count_type_id(Keyword.get(opts, :type_id))
|> get_used_count_pack_id(Keyword.get(opts, :pack_id))
|> Repo.one() || 0
@spec get_used_count_for_type(Type.t(), User.t()) :: non_neg_integer()
def get_used_count_for_type(%Type{id: type_id} = type, user) do
[type]
|> get_used_count_for_types(user)
|> Map.get(type_id, 0)
end
@spec get_used_count_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp get_used_count_pack_id(query, pack_id) when pack_id |> is_binary() do
query |> where([sr: sr], sr.pack_id == ^pack_id)
end
defp get_used_count_pack_id(query, _nil), do: query
@spec get_used_count_type_id(Queryable.t(), Type.id() | nil) :: Queryable.t()
defp get_used_count_type_id(query, type_id) when type_id |> is_binary() do
query |> where([p: p], p.type_id == ^type_id)
end
defp get_used_count_type_id(query, _nil), do: query
@type get_grouped_used_counts_option ::
{:packs, [Pack.t()] | nil}
| {:types, [Type.t()] | nil}
| {:group_by, :type_id | :pack_id}
@type get_grouped_used_counts_options :: [get_grouped_used_counts_option()]
@doc """
Gets the total number of rounds shot for multiple types or packs
Gets the total number of rounds shot for multiple types
## Examples
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :type_id,
...> types: [%Type{id: 456, user_id: 123}]
...> )
iex> get_used_count_for_types(123, %User{id: 123})
35
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :pack_id,
...> packs: [%Pack{id: 456, user_id: 123}]
...> )
22
"""
@spec get_grouped_used_counts(User.t(), get_grouped_used_counts_options()) ::
%{optional(Type.id() | Pack.id()) => non_neg_integer()}
def get_grouped_used_counts(%User{id: user_id}, opts) do
from(p in Pack,
as: :p,
left_join: sr in ShotRecord,
on: p.id == sr.pack_id,
on: p.user_id == ^user_id,
as: :sr,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil())
@spec get_used_count_for_types([Type.t()], User.t()) ::
%{optional(Type.id()) => non_neg_integer()}
def get_used_count_for_types(types, %User{id: user_id}) do
type_ids =
types
|> Enum.map(fn %Type{id: type_id, user_id: ^user_id} -> type_id end)
Repo.all(
from ag in Pack,
left_join: sg in ShotRecord,
on: ag.id == sg.pack_id,
where: ag.type_id in ^type_ids,
where: not (sg.count |> is_nil()),
group_by: ag.type_id,
select: {ag.type_id, sum(sg.count)}
)
|> get_grouped_used_counts_group_by(Keyword.fetch!(opts, :group_by))
|> get_grouped_used_counts_types(Keyword.get(opts, :types))
|> get_grouped_used_counts_packs(Keyword.get(opts, :packs))
|> Repo.all()
|> Map.new()
end
@spec get_grouped_used_counts_group_by(Queryable.t(), :type_id | :pack_id) :: Queryable.t()
defp get_grouped_used_counts_group_by(query, :type_id) do
query
|> group_by([p: p], p.type_id)
|> select([sr: sr, p: p], {p.type_id, sum(sr.count)})
end
defp get_grouped_used_counts_group_by(query, :pack_id) do
query
|> group_by([sr: sr], sr.pack_id)
|> select([sr: sr], {sr.pack_id, sum(sr.count)})
end
@spec get_grouped_used_counts_types(Queryable.t(), [Type.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_types(query, types) when types |> is_list() do
type_ids = types |> Enum.map(fn %Type{id: type_id} -> type_id end)
query |> where([p: p], p.type_id in ^type_ids)
end
defp get_grouped_used_counts_types(query, _nil), do: query
@spec get_grouped_used_counts_packs(Queryable.t(), [Pack.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_packs(query, packs) when packs |> is_list() do
pack_ids = packs |> Enum.map(fn %Pack{id: pack_id} -> pack_id end)
query |> where([p: p], p.id in ^pack_ids)
end
defp get_grouped_used_counts_packs(query, _nil), do: query
end

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,8 +5,11 @@ defmodule Cannery.Ammo.Type do
Contains statistical information about the ammunition.
"""
use Cannery, :schema
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Cannery.Ammo.Pack
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
@ -43,13 +46,17 @@ defmodule Cannery.Ammo.Type do
:shot_charge_weight,
:dram_equivalent
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "types" do
field :name, :string
field :desc, :string
field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol], default: :rifle
field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol]
# common fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string
field :bullet_core, :string
# also gauge for shotguns
field :caliber, :string
@ -68,8 +75,6 @@ defmodule Cannery.Ammo.Type do
field :corrosive, :boolean, default: false
# rifle/pistol fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string
field :cartridge, :string
field :jacket_type, :string
field :powder_grains_per_charge, :integer
@ -90,7 +95,7 @@ defmodule Cannery.Ammo.Type do
field :user_id, :binary_id
has_many :packs, Pack
timestamps(type: :utc_datetime_usec)
timestamps()
end
@type t :: %__MODULE__{
@ -129,8 +134,8 @@ defmodule Cannery.Ammo.Type do
dram_equivalent: String.t() | nil,
user_id: User.id(),
packs: [Pack.t()] | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_type :: %__MODULE__{}
@type id :: UUID.t()

View File

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

View File

@ -3,17 +3,14 @@ defmodule Cannery.Containers do
The Containers context.
"""
use Cannery, :context
alias Cannery.Ammo.Pack
import CanneryWeb.Gettext
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.Pack, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.Changeset
@container_preloads [:tags]
@type list_containers_option ::
{:search, String.t() | nil}
| {:staged, boolean() | nil}
@type list_containers_options :: [list_containers_option()]
@doc """
Returns the list of containers.
@ -22,41 +19,30 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123})
[%Container{}, ...]
iex> list_containers(%User{id: 123},
...> search: "cool",
...> staged: true
...> )
iex> list_containers("cool", %User{id: 123})
[%Container{name: "my cool container"}, ...]
"""
@spec list_containers(User.t()) :: [Container.t()]
@spec list_containers(User.t(), list_containers_options()) :: [Container.t()]
def list_containers(%User{id: user_id}, opts \\ []) do
@spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()]
def list_containers(search \\ nil, %User{id: user_id}) do
from(c in Container,
as: :c,
left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t,
where: c.user_id == ^user_id,
order_by: c.name,
distinct: c.id,
preload: ^@container_preloads
)
|> list_containers_search(Keyword.get(opts, :search))
|> list_containers_staged(Keyword.get(opts, :staged))
|> list_containers_search(search)
|> Repo.all()
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_search(query, nil), do: query
defp list_containers_search(query, ""), do: query
defp list_containers_staged(query, _nil), do: query
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_containers_search(query, search) when search in ["", nil],
do: query |> order_by([c: c], c.name)
defp list_containers_search(query, search) when search |> is_binary() do
defp list_containers_search(query, search) do
trimmed_search = String.trim(search)
query
@ -217,9 +203,9 @@ defmodule Cannery.Containers do
{:ok, Container.t()} | {:error, Container.changeset()}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one(
from p in Pack,
where: p.container_id == ^container.id,
select: count(p.id)
from ag in Pack,
where: ag.container_id == ^container.id,
select: count(ag.id)
)
|> case do
0 ->
@ -303,9 +289,6 @@ defmodule Cannery.Containers do
# Container Tags
@type list_tags_option :: {:search, String.t() | nil}
@type list_tags_options :: [list_tags_option()]
@doc """
Returns the list of tags.
@ -314,42 +297,38 @@ defmodule Cannery.Containers do
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags(%User{id: 123}, search: "cool")
iex> list_tags("cool", %User{id: 123})
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(User.t(), list_tags_options()) :: [Tag.t()]
def list_tags(%User{id: user_id}, opts \\ []) do
from(t in Tag, as: :t, where: t.user_id == ^user_id)
|> list_tags_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()]
def list_tags(search \\ nil, user)
@spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_tags_search(query, search) when search in ["", nil],
do: query |> order_by([t: t], t.name)
def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "",
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
defp list_tags_search(query, search) when search |> is_binary() do
def list_tags(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
Repo.all(
from t in Tag,
where: t.user_id == ^user_id,
where:
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
),
order_by: {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
}
)
|> order_by([t: t], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
})
end
@doc """

View File

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

View File

@ -4,14 +4,18 @@ defmodule Cannery.Containers.ContainerTag do
Cannery.Containers.Tag.
"""
use Cannery, :schema
use Ecto.Schema
import Ecto.Changeset
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
belongs_to :container, Container
belongs_to :tag, Tag
timestamps(type: :utc_datetime_usec)
timestamps()
end
@type t :: %__MODULE__{
@ -20,8 +24,8 @@ defmodule Cannery.Containers.ContainerTag do
container_id: Container.id(),
tag: Tag.t(),
tag_id: Tag.id(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container_tag :: %__MODULE__{}
@type id :: UUID.t()

View File

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

View File

@ -14,17 +14,17 @@ defmodule Cannery.Logger do
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode()
Logger.error("Oban exception: #{data}")
Logger.error(meta.reason, data: data)
end
def handle_event([:oban, :job, :start], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Started oban job: #{data}")
Logger.info("Started oban job", data: data)
end
def handle_event([:oban, :job, :stop], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Finished oban job: #{data}")
Logger.info("Finished oban job", data: data)
end
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
@ -33,7 +33,7 @@ defmodule Cannery.Logger do
|> Map.put(:event, unhandled_event)
|> pretty_encode()
Logger.warning("Unhandled oban job event: #{data}")
Logger.warning("Unhandled oban job event", data: data)
end
def handle_event(unhandled_event, measure, meta, config) do
@ -45,7 +45,7 @@ defmodule Cannery.Logger do
config: config
})
Logger.warning("Unhandled telemetry event: #{data}")
Logger.warning("Unhandled telemetry event", data: data)
end
defp get_oban_job_data(%{job: job}, measure) do

View File

@ -1,62 +1,53 @@
defmodule CanneryWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
as controllers, views, channels and so on.
This can be used in your application as:
use CanneryWeb, :controller
use CanneryWeb, :html
use CanneryWeb, :view
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
below. Instead, define any helper function in modules
and import those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts]
use Phoenix.Controller, namespace: CanneryWeb
use Gettext, backend: CanneryWeb.Gettext
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
import CanneryWeb.Gettext
alias CanneryWeb.Router.Helpers, as: Routes
end
end
unquote(verified_routes())
def view do
quote do
use Phoenix.View,
root: "lib/cannery_web/templates",
namespace: CanneryWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {CanneryWeb.Layouts, :app}
use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live}
unquote(html_helpers())
on_mount CanneryWeb.InitAssigns
unquote(view_helpers())
end
end
@ -64,45 +55,49 @@ defmodule CanneryWeb do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
unquote(view_helpers())
end
end
def html do
def component do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
unquote(view_helpers())
end
end
defp html_helpers do
def router do
quote do
use PhoenixHTMLHelpers
use Gettext, backend: CanneryWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
use Phoenix.Router
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
import Phoenix.{Controller, LiveView.Router}
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
end
end
def verified_routes do
def channel do
quote do
use Phoenix.VerifiedRoutes,
endpoint: CanneryWeb.Endpoint,
router: CanneryWeb.Router,
statics: CanneryWeb.static_paths()
use Phoenix.Channel
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.Gettext
end
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
# Import basic rendering functionality (render, render_layout, etc)
import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers}
import Phoenix.{Component, View}
alias CanneryWeb.Endpoint
alias CanneryWeb.Router.Helpers, as: Routes
end
end

View File

@ -33,9 +33,7 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
) do
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 =
case changeset |> Changeset.apply_action(:validate) do

View File

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

View File

@ -4,7 +4,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -14,7 +13,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:containers) => [Container.t()],
optional(:range) => Rendered.t(),
optional(:tag_actions) => Rendered.t(),
optional(:actions) => Rendered.t(),
optional(any()) => any()
@ -25,7 +23,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
socket =
socket
|> assign(assigns)
|> assign_new(:range, fn -> [] end)
|> assign_new(:tag_actions, fn -> [] end)
|> assign_new(:actions, fn -> [] end)
|> display_containers()
@ -38,7 +35,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns: %{
containers: containers,
current_user: current_user,
range: range,
tag_actions: tag_actions,
actions: actions
}
@ -66,34 +62,17 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end)
|> Enum.concat([
%{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 = %{
current_user: current_user,
range: range,
tag_actions: tag_actions,
actions: actions,
pack_count:
Ammo.get_grouped_packs_count(current_user,
containers: containers,
group_by: :container_id
),
round_count:
Ammo.get_grouped_round_count(current_user,
containers: containers,
group_by: :container_id
)
pack_count: Ammo.get_packs_count_for_containers(containers, current_user),
round_count: Ammo.get_round_count_for_containers(containers, current_user)
}
rows =
@ -130,12 +109,14 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end
@spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any()
defp get_value_for_key(:name, %{name: container_name} = assigns, _extra_data) do
defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do
assigns = %{id: id, container_name: container_name}
{container_name,
~H"""
<div class="flex flex-wrap justify-center items-center">
<.link navigate={~p"/container/#{@id}"} class="link">
{@name}
<.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link">
<%= @container_name %>
</.link>
</div>
"""}
@ -149,15 +130,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
round_count |> Map.get(container_id, 0)
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
assigns = %{tag_actions: tag_actions, container: container}
@ -172,7 +144,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
<div class="flex flex-wrap justify-center items-center">
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
{render_slot(@tag_actions, @container)}
<%= render_slot(@tag_actions, @container) %>
</div>
"""}
end
@ -181,7 +153,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
assigns = %{actions: actions, container: container}
~H"""
{render_slot(@actions, @container)}
<%= render_slot(@actions, @container) %>
"""
end

View File

@ -3,12 +3,11 @@ defmodule CanneryWeb.CoreComponents do
Provides core UI components.
"""
use Phoenix.Component
use CanneryWeb, :verified_routes
use Gettext, backend: CanneryWeb.Gettext
import CanneryWeb.HTMLHelpers
import CanneryWeb.{Gettext, ViewHelpers}
alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.Pack}
alias Cannery.{Containers.Container, Containers.Tag}
alias CanneryWeb.{Endpoint, HomeLive}
alias CanneryWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.{JS, Rendered}
@ -30,13 +29,13 @@ defmodule CanneryWeb.CoreComponents do
## Examples
<.modal return_to={~p"/\#{<%= schema.plural %>}"}>
<.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
<.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new}
title={@page_title}
action={@live_action}
return_to={~p"/\#{<%= schema.singular %>}"}
return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
<%= schema.singular %>: @<%= schema.singular %>
/>
</.modal>
@ -136,26 +135,14 @@ defmodule CanneryWeb.CoreComponents do
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """
Phoenix.Component for a <time> element that renders the DateTime in the
Phoenix.Component for a <time> element that renders the naivedatetime in the
user's local timezone
"""
def datetime(assigns)
attr :name, :string, required: true
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)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
end
defp cast_datetime(_datetime), do: ""

View File

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

View File

@ -1,3 +1,3 @@
<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>

View File

@ -1,15 +0,0 @@
<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">
{cast_datetime(@datetime)}
<%= cast_datetime(@datetime) %>
</time>

View File

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

View File

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

View File

@ -2,66 +2,66 @@
id={"pack-#{@pack.id}"}
class="mx-4 my-2 px-8 py-4
flex flex-col justify-center items-center
border border-zinc-400 rounded-lg shadow-lg hover:shadow-md
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link">
<.link navigate={Routes.pack_show_path(Endpoint, :show, @pack)} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
{@pack.type.name}
<%= @pack.type.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
{gettext("Count:")}
{if @pack.count == 0, do: gettext("Empty"), else: @pack.count}
<%= gettext("Count:") %>
<%= if @pack.count == 0, do: gettext("Empty"), else: @pack.count %>
</span>
<span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg">
{gettext("Original Count:")}
{@original_count}
<%= gettext("Original Count:") %>
<%= @original_count %>
</span>
<span :if={@pack.notes} class="rounded-lg title text-lg">
{gettext("Notes:")}
{@pack.notes}
<%= gettext("Notes:") %>
<%= @pack.notes %>
</span>
<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} />
</span>
<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} />
</span>
<span :if={@pack.price_paid} class="rounded-lg title text-lg">
{gettext("Price paid:")}
{gettext("$%{amount}", amount: display_currency(@pack.price_paid))}
<%= gettext("Price paid:") %>
<%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %>
</span>
<span :if={@cpr} class="rounded-lg title text-lg">
{gettext("CPR:")}
{gettext("$%{amount}", amount: display_currency(@cpr))}
<%= gettext("CPR:") %>
<%= gettext("$%{amount}", amount: display_currency(@cpr)) %>
</span>
<span :if={@pack.lot_number} class="rounded-lg title text-lg">
{gettext("Lot number:")}
{@pack.lot_number}
<%= gettext("Lot number:") %>
<%= @pack.lot_number %>
</span>
<span :if={@container} class="rounded-lg title text-lg">
{gettext("Container:")}
<%= gettext("Container:") %>
<.link navigate={~p"/container/#{@container}"} class="link">
{@container.name}
<.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link">
<%= @container.name %>
</.link>
</span>
</div>
<div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center">
{render_slot(@inner_block)}
<%= render_slot(@inner_block) %>
</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"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
>
{@tag.name}
<%= @tag.name %>
</h1>

View File

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

View File

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

View File

@ -1,9 +1,12 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link navigate={~p"/"} class="inline mx-2 my-1 leading-5 text-xl text-white">
<.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img
src={~p"/images/cannery.svg"}
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
@ -14,7 +17,7 @@
<span class="mx-2 my-1">
|
</span>
{@title_content}
<%= @title_content %>
<% end %>
</div>
@ -24,43 +27,64 @@
text-lg text-white text-ellipsis">
<%= if @current_user do %>
<li class="mx-2 my-1">
<.link navigate={~p"/tags"} class="text-white hover:underline">
{gettext("Tags")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/containers"} class="text-white hover:underline">
{gettext("Containers")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/catalog"} class="text-white hover:underline">
{gettext("Catalog")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/ammo"} class="text-white hover:underline">
{gettext("Ammo")}
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/range"} class="text-white hover:underline">
{gettext("Range")}
</.link>
</li>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-white hover:underline">
{gettext("Invites")}
</.link>
</li>
<li class="mx-2 my-1">
<.link href={~p"/users/settings"} class="text-white hover:underline truncate">
{@current_user.email}
<.link
navigate={Routes.tag_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Tags") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={~p"/users/log_out"}
navigate={Routes.container_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.type_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.pack_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Range") %>
</.link>
</li>
<li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Invites") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_settings_path(Endpoint, :edit)}
class="text-white hover:underline truncate"
>
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
aria-label={gettext("Log out")}
@ -70,13 +94,13 @@
</li>
<li
:if={
@current_user |> Accounts.already_admin?() and
@current_user |> Accounts.is_already_admin?() and
function_exported?(Routes, :live_dashboard_path, 2)
}
class="mx-2 my-1"
>
<.link
navigate={~p"/dashboard"}
navigate={Routes.live_dashboard_path(Endpoint, :home)}
class="text-white hover:underline"
aria-label={gettext("Live Dashboard")}
>
@ -85,13 +109,19 @@
</li>
<% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link href={~p"/users/register"} class="text-white hover:underline truncate">
{dgettext("actions", "Register")}
<.link
href={Routes.user_registration_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link href={~p"/users/log_in"} class="text-white hover:underline truncate">
{dgettext("actions", "Log in")}
<.link
href={Routes.user_session_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %>
</.link>
</li>
<% end %>

View File

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

View File

@ -1,10 +0,0 @@
defmodule CanneryWeb.EmailHTML do
@moduledoc """
Renders email templates
"""
use CanneryWeb, :html
embed_templates "email_html/*.html", suffix: "_html"
embed_templates "email_html/*.txt", suffix: "_text"
end

View File

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

View File

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

View File

@ -1,17 +0,0 @@
defmodule CanneryWeb.Layouts do
@moduledoc """
The root layouts for the entire application
"""
use CanneryWeb, :html
embed_templates "layouts/*"
def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do
gettext("Cannery | %{title}", title: title)
end
def get_title(_conn) do
gettext("Cannery")
end
end

View File

@ -1 +0,0 @@
{@inner_block}

View File

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

View File

@ -5,6 +5,7 @@ defmodule CanneryWeb.Components.MovePackComponent do
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Ecto.Changeset
alias Phoenix.LiveView.Socket
@ -74,22 +75,22 @@ defmodule CanneryWeb.Components.MovePackComponent do
~H"""
<div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600">
{dgettext("actions", "Move ammo")}
<%= dgettext("actions", "Move ammo") %>
</h2>
<%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600">
{gettext("No other containers")}
{display_emoji("😔")}
<%= gettext("No other containers") %>
<%= display_emoji("😔") %>
</h2>
<.link navigate={~p"/containers/new"} class="btn btn-primary">
{dgettext("actions", "Add another container!")}
<.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %>
</.link>
<% else %>
<.live_component
module={CanneryWeb.Components.TableComponent}
id="move-pack-table"
id="move_pack_table"
columns={@columns}
rows={@rows}
/>
@ -120,7 +121,7 @@ defmodule CanneryWeb.Components.MovePackComponent do
phx-target={@myself}
phx-value-container_id={@container.id}
>
{dgettext("actions", "Select")}
<%= dgettext("actions", "Select") %>
</button>
</div>
"""

View File

@ -141,12 +141,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={TableComponent}
id={"pack-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
<.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} />
</div>
"""
end
@ -170,7 +165,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
{type_name,
~H"""
{render_slot(@type_block, @type)}
<%= render_slot(@type_block, @type) %>
"""}
end
@ -196,17 +191,18 @@ defmodule CanneryWeb.Components.PackTableComponent do
<%= if @last_used_date do %>
<.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %>
{gettext("Never used")}
<%= gettext("Never used") %>
<% end %>
"""}
end
defp get_value_for_key(:range, pack, %{range: range}) do
defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do
assigns = %{range: range, pack: pack}
~H"""
{render_slot(@range, @pack)}
"""
{staged,
~H"""
<%= render_slot(@range, @pack) %>
"""}
end
defp get_value_for_key(
@ -222,7 +218,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
assigns = %{actions: actions, pack: pack}
~H"""
{render_slot(@actions, @pack)}
<%= render_slot(@actions, @pack) %>
"""
end
@ -243,7 +239,7 @@ defmodule CanneryWeb.Components.PackTableComponent do
{container_name,
~H"""
{render_slot(@container_block, {@pack, @container})}
<%= render_slot(@container_block, {@pack, @container}) %>
"""}
end

View File

@ -74,7 +74,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
<div id={@id} class="w-full">
<.live_component
module={CanneryWeb.Components.TableComponent}
id={"shot-record-table-#{@id}"}
id={"table-#{@id}"}
columns={@columns}
rows={@rows}
initial_key={:date}
@ -98,8 +98,8 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
{pack.type.name,
~H"""
<.link navigate={~p"/ammo/show/#{@pack}"} class="link">
{@pack.type.name}
<.link navigate={Routes.pack_show_path(Endpoint, :show, @pack)} class="link">
<%= @pack.type.name %>
</.link>
"""}
end
@ -115,7 +115,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
assigns = %{actions: actions, shot_record: shot_record}
~H"""
{render_slot(@actions, @shot_record)}
<%= render_slot(@actions, @shot_record) %>
"""
end

View File

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

View File

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

View File

@ -55,6 +55,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do
%{label: gettext("Unfired shell length"), key: :unfired_length, type: :string},
%{label: gettext("Brass height"), key: :brass_height, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Grains"), key: :grains, type: :string},
%{label: gettext("Bullet type"), key: :bullet_type, type: :string},
%{
@ -151,25 +152,17 @@ defmodule CanneryWeb.Components.TypeTableComponent do
)
|> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name})
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
packs_count = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
average_costs = Ammo.get_average_costs(types, current_user)
round_counts = types |> Ammo.get_round_count_for_types(current_user)
packs_count = types |> Ammo.get_packs_count_for_types(current_user)
average_costs = types |> Ammo.get_average_cost_for_types(current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do
[
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id),
Ammo.get_historical_counts(types, current_user),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: :only_used
)
types |> ActivityLog.get_used_count_for_types(current_user),
types |> Ammo.get_historical_count_for_types(current_user),
types |> Ammo.get_packs_count_for_types(current_user, true),
types |> Ammo.get_used_packs_count_for_types(current_user)
]
else
[nil, nil, nil, nil]
@ -200,12 +193,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={TableComponent}
id={"type-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
<.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} />
</div>
"""
end
@ -274,11 +262,13 @@ defmodule CanneryWeb.Components.TypeTableComponent do
end
end
defp get_type_value(:name, _key, %{name: type_name} = assigns, _other_data) do
defp get_type_value(:name, _key, %{name: type_name} = type, _other_data) do
assigns = %{type: type}
{type_name,
~H"""
<.link navigate={~p"/type/#{@id}"} class="link">
{@name}
<.link navigate={Routes.type_show_path(Endpoint, :show, @type)} class="link">
<%= @type.name %>
</.link>
"""}
end
@ -287,7 +277,7 @@ defmodule CanneryWeb.Components.TypeTableComponent do
assigns = %{actions: actions, type: type}
~H"""
{render_slot(@actions, @type)}
<%= render_slot(@actions, @type) %>
"""
end

View File

@ -6,8 +6,7 @@ defmodule CanneryWeb.EmailController do
use CanneryWeb, :controller
alias Cannery.Accounts.User
plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html}
plug :put_layout, false
plug :put_layout, {CanneryWeb.LayoutView, :email}
@sample_assigns %{
email: %{subject: "Example subject"},
@ -19,6 +18,6 @@ defmodule CanneryWeb.EmailController do
Debug route used to preview emails
"""
def preview(conn, %{"id" => template}) do
render(conn, String.to_existing_atom(template), @sample_assigns)
render(conn, "#{template |> to_string()}.html", @sample_assigns)
end
end

View File

@ -1,16 +0,0 @@
defmodule CanneryWeb.ErrorHTML do
use CanneryWeb, :html
embed_templates "error_html/*"
def render(template, _assigns) do
error_string =
case template do
"404.html" -> dgettext("errors", "Not found")
"401.html" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
error(%{error_string: error_string})
end
end

View File

@ -1,14 +0,0 @@
defmodule CanneryWeb.ErrorJSON do
use Gettext, backend: CanneryWeb.Gettext
def render(template, _assigns) do
error_string =
case template do
"404.json" -> dgettext("errors", "Not found")
"401.json" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
%{errors: %{detail: error_string}}
end
end

View File

@ -3,22 +3,14 @@ defmodule CanneryWeb.ExportController do
alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
types = Ammo.list_types(current_user)
types = Ammo.list_types(current_user, :all)
used_counts = types |> ActivityLog.get_used_count_for_types(current_user)
round_counts = types |> Ammo.get_round_count_for_types(current_user)
pack_counts = types |> Ammo.get_packs_count_for_types(current_user)
used_counts =
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id)
total_pack_counts = types |> Ammo.get_packs_count_for_types(current_user, true)
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
total_pack_counts =
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
)
average_costs = Ammo.get_average_costs(types, current_user)
average_costs = types |> Ammo.get_average_cost_for_types(current_user)
types =
types
@ -35,11 +27,8 @@ defmodule CanneryWeb.ExportController do
})
end)
packs = Ammo.list_packs(current_user, show_used: true)
used_counts =
ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id)
packs = Ammo.list_packs(nil, :all, current_user, true)
used_counts = packs |> ActivityLog.get_used_counts(current_user)
original_counts = packs |> Ammo.get_original_counts(current_user)
cprs = packs |> Ammo.get_cprs(current_user)
percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user)
@ -58,17 +47,20 @@ defmodule CanneryWeb.ExportController do
})
end)
shot_records = ActivityLog.list_shot_records(current_user)
shot_records = ActivityLog.list_shot_records(:all, current_user)
containers =
Containers.list_containers(current_user)
|> Enum.map(fn container ->
pack_count = container |> Ammo.get_packs_count_for_container!(current_user)
round_count = container |> Ammo.get_round_count_for_container!(current_user)
container
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"pack_count" => Ammo.get_packs_count(current_user, container_id: container.id),
"round_count" => Ammo.get_round_count(current_user, container_id: container.id)
"pack_count" => pack_count,
"round_count" => round_count
})
end)

View File

@ -0,0 +1,11 @@
defmodule CanneryWeb.HomeController do
@moduledoc """
Controller for home page
"""
use CanneryWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -1,5 +0,0 @@
defmodule CanneryWeb.HomeHTML do
use CanneryWeb, :html
embed_templates "home_html/*"
end

View File

@ -3,11 +3,12 @@ defmodule CanneryWeb.UserAuth do
Functions for user session and authentication
"""
use CanneryWeb, :verified_routes
use Gettext, backend: CanneryWeb.Gettext
import Plug.Conn
import Phoenix.Controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.User}
alias CanneryWeb.HomeLive
alias CanneryWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
@ -38,7 +39,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in")
|> redirect(to: Routes.user_session_path(conn, :new))
|> halt()
end
@ -48,7 +49,8 @@ defmodule CanneryWeb.UserAuth do
conn
|> renew_session()
|> put_token_in_session(token)
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@ -94,7 +96,7 @@ defmodule CanneryWeb.UserAuth do
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
user_token && Accounts.delete_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -103,7 +105,7 @@ defmodule CanneryWeb.UserAuth do
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
@doc """
@ -117,110 +119,19 @@ defmodule CanneryWeb.UserAuth do
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
if user_token = get_session(conn, :user_token) do
{user_token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
if user_token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule CanneryWeb.PageLive do
use CanneryWeb, :live_view
on_mount {CanneryWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{CanneryWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(session, socket)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:ensure_admin, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user && socket.assigns.current_user.role == :admin do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in as an administrator to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(session, socket) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
@ -250,7 +161,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in")
|> redirect(to: Routes.user_session_path(conn, :new))
|> halt()
end
end
@ -265,34 +176,16 @@ defmodule CanneryWeb.UserAuth do
conn
|> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
|> maybe_store_return_to()
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(conn, HomeLive))
|> halt()
end
end
def put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(locale || default)
conn |> put_session(:locale, locale || default)
end
def put_user_locale(conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(default)
conn |> put_session(:locale, default)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/"
defp signed_in_path(_conn), do: "/"
end

View File

@ -1,16 +1,18 @@
defmodule CanneryWeb.UserConfirmationController do
use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.Accounts
def new(conn, _params) do
render(conn, :new, page_title: gettext("Confirm your account"))
render(conn, "new.html", page_title: gettext("Confirm your account"))
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
&Routes.user_confirmation_url(conn, :confirm, &1)
)
end
@ -20,10 +22,11 @@ defmodule CanneryWeb.UserConfirmationController do
:info,
dgettext(
"prompts",
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
# Do not log in the user after confirmation to avoid a
@ -33,7 +36,7 @@ defmodule CanneryWeb.UserConfirmationController do
{:ok, %{email: email}} ->
conn
|> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
|> redirect(to: ~p"/")
|> redirect(to: "/")
:error ->
# If there is a current user and the account was already confirmed,
@ -42,7 +45,7 @@ defmodule CanneryWeb.UserConfirmationController do
# a warning message.
case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: ~p"/")
redirect(conn, to: "/")
%{} ->
conn
@ -50,7 +53,7 @@ defmodule CanneryWeb.UserConfirmationController do
:error,
dgettext("errors", "User confirmation link is invalid or it has expired.")
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
end
end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserConfirmationHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_confirmation_html/*"
end

View File

@ -1,31 +0,0 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl">
{dgettext("actions", "Resend confirmation instructions")}
</h1>
<.form
:let={f}
for={%{}}
as={:user}
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"
>
{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")}
{submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<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">
{dgettext("actions", "Register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "Log in")}
</.link>
</div>
</div>

View File

@ -1,6 +1,8 @@
defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.Invites}
alias CanneryWeb.{Endpoint, HomeLive}
alias Ecto.Changeset
def new(conn, %{"invite" => invite_token}) do
@ -9,7 +11,7 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
@ -19,13 +21,13 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
# renders new user registration page
defp render_new(conn, invite_token \\ nil) do
render(conn, :new,
render(conn, "new.html",
changeset: Accounts.change_user_registration(),
invite_token: invite_token,
page_title: gettext("Register")
@ -38,7 +40,7 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
@ -48,7 +50,7 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
@ -57,20 +59,20 @@ defmodule CanneryWeb.UserRegistrationController do
{:ok, user} ->
Accounts.deliver_user_confirmation_instructions(
user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
&Routes.user_confirmation_url(conn, :confirm, &1)
)
conn
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|> redirect(to: ~p"/users/log_in")
|> redirect(to: Routes.user_session_path(Endpoint, :new))
{:error, :invalid_token} ->
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
{:error, %Changeset{} = changeset} ->
conn |> render(:new, changeset: changeset, invite_token: invite_token)
conn |> render("new.html", changeset: changeset, invite_token: invite_token)
end
end
end

View File

@ -1,5 +0,0 @@
defmodule CanneryWeb.UserRegistrationHTML do
use CanneryWeb, :html
embed_templates "user_registration_html/*"
end

View File

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

View File

@ -6,14 +6,14 @@ defmodule CanneryWeb.UserResetPasswordController do
plug :get_user_by_reset_password_token when action in [:edit, :update]
def new(conn, _params) do
render(conn, :new, page_title: gettext("Forgot your password?"))
render(conn, "new.html", page_title: gettext("Forgot your password?"))
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/reset_password/#{token}") end
&Routes.user_reset_password_url(conn, :edit, &1)
)
end
@ -23,14 +23,15 @@ defmodule CanneryWeb.UserResetPasswordController do
:info,
dgettext(
"prompts",
"If your email is in our system, you will receive instructions to reset your password shortly."
"If your email is in our system, you will receive instructions to " <>
"reset your password shortly."
)
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
def edit(conn, _params) do
render(conn, :edit,
render(conn, "edit.html",
changeset: Accounts.change_user_password(conn.assigns.user),
page_title: gettext("Reset your password")
)
@ -40,13 +41,13 @@ defmodule CanneryWeb.UserResetPasswordController do
# leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _socket} ->
{:ok, _} ->
conn
|> put_flash(:info, dgettext("prompts", "Password reset successfully."))
|> redirect(to: ~p"/users/log_in")
|> redirect(to: Routes.user_session_path(conn, :new))
{:error, changeset} ->
render(conn, :edit, changeset: changeset)
render(conn, "edit.html", changeset: changeset)
end
end
@ -61,7 +62,7 @@ defmodule CanneryWeb.UserResetPasswordController do
:error,
dgettext("errors", "Reset password link is invalid or it has expired.")
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
|> halt()
end
end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserResetPasswordHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_reset_password_html/*"
end

View File

@ -1,44 +0,0 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl">
{dgettext("actions", "Reset password")}
</h1>
<.form
:let={f}
for={@changeset}
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"
>
<p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3">
{dgettext("errors", "Oops, something went wrong! Please check the errors below.")}
</p>
{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")}
{error_tag(f, :password, "col-span-3")}
{label(f, :password_confirmation, gettext("Confirm new password"),
class: "title text-lg text-primary-600"
)}
{password_input(f, :password_confirmation,
required: true,
class: "input input-primary col-span-2"
)}
{error_tag(f, :password_confirmation, "col-span-3")}
{submit(dgettext("actions", "Reset password"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<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">
{dgettext("actions", "Register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "Log in")}
</.link>
</div>
</div>

View File

@ -1,31 +0,0 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl">
{dgettext("actions", "Forgot your password?")}
</h1>
<.form
:let={f}
for={%{}}
as={:user}
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"
>
{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")}
{submit(dgettext("actions", "Send instructions to reset password"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<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">
{dgettext("actions", "Register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "Log in")}
</.link>
</div>
</div>

View File

@ -5,7 +5,7 @@ defmodule CanneryWeb.UserSessionController do
alias CanneryWeb.UserAuth
def new(conn, _params) do
render(conn, :new, error_message: nil, page_title: gettext("Log in"))
render(conn, "new.html", error_message: nil, page_title: gettext("Log in"))
end
def create(conn, %{"user" => user_params}) do
@ -14,7 +14,7 @@ defmodule CanneryWeb.UserSessionController do
if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params)
else
render(conn, :new, error_message: dgettext("errors", "Invalid email or password"))
render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password"))
end
end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserSessionHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_session_html/*"
end

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