Compare commits

...

33 Commits

Author SHA1 Message Date
Adam Piontek 2624a32861 use purgecss for much smaller css 2023-01-28 09:53:32 -05:00
Adam Piontek c6c52c783e remove unnecessary Dockerfile.bak file 2023-01-28 09:37:42 -05:00
Adam Piontek 433704e08a implement autoprefixer & cssnano with postcss 2023-01-28 09:28:54 -05:00
Adam Piontek a3c3940563 removed unused svg-sprite-generator npm dep 2023-01-28 09:02:38 -05:00
Adam Piontek 573a1e9cfe move more fully to runtime config with release, improved docker build, updated phx liveview js 2023-01-28 08:14:32 -05:00
Adam Piontek db9f127e7b major deps update: phoenix liveview, fixing some minor issues 2023-01-27 18:01:18 -05:00
Adam Piontek bc36586212 dep major update: httpoison 2.x 2023-01-27 17:37:22 -05:00
Adam Piontek 66bb2a8727 updated several deps 2023-01-27 17:34:05 -05:00
Adam Piontek 7a879e632a updated asset npm dependencies & rebuilt assets 2023-01-27 17:29:13 -05:00
Adam Piontek 280cd5bc34 update assets to try to fix favicon issue 2023-01-27 17:24:57 -05:00
Adam Piontek b3d1099d34 add some basic env vars info to README 2023-01-27 17:18:59 -05:00
Adam Piontek d4b810e14e initial work on a releases runtime config 2023-01-27 17:15:24 -05:00
Adam Piontek ba5957cc93 remove sample dockerfile & entrypoint as they're now included in the repo 2022-08-14 15:25:59 -04:00
Adam Piontek 94637a564c added Dockerfile and entrypoint script 2022-08-14 15:14:14 -04:00
Adam Piontek 10f284da6f don't gzip static files, and always commit them 2022-08-14 15:09:30 -04:00
Adam Piontek 64e310b598 removed accidentally commited old assets dir 2022-08-14 14:33:42 -04:00
Adam Piontek 61796cf985 npm update, remove runtime.exs as not using for now 2022-08-14 13:58:41 -04:00
Adam Piontek dceef941c7 fixed user delete error by correcting foreign key constraint; updated liveview modals to use component directly & removed deprecated @socket parameters 2022-08-14 12:49:25 -04:00
Adam Piontek 68d60c120d removed unnecessary @socket param to live_component in settings; removed now-unused icon_helpers 2022-08-14 11:44:31 -04:00
Adam Piontek 6b787297bb fix EEx tag typo 2022-08-14 11:37:40 -04:00
Adam Piontek 24642d7c67 main shift assign UI updated, with custom shift field fixes & shift template form field fix for time_zone 2022-08-14 11:25:28 -04:00
Adam Piontek 8cd984adc5 updated import-shifts template 2022-08-14 10:43:16 -04:00
Adam Piontek e7d93989d3 fixed csv export view fields required issue 2022-08-14 10:37:55 -04:00
Adam Piontek a99c5eea35 updated shift index template and implemented delete shift as modal instead of js alert 2022-08-14 10:30:57 -04:00
Adam Piontek ada166fb41 updated template management, including time_input: required fix 2022-08-14 09:49:34 -04:00
Adam Piontek 6a5d2346ff cleaned up parentheses in schemas 2022-08-14 09:22:39 -04:00
Adam Piontek f28c85e343 cleaned up parentheses in migrations 2022-08-14 09:16:58 -04:00
Adam Piontek f27df8d676 implemented optional 'allow_registration' config, with first registered user being pre-confirmed Admin, registration unavailable after that point if allow_registration: :false 2022-08-14 09:14:42 -04:00
Adam Piontek ea74a89078 user management new/edit/delete working, with fixed live modal 2022-08-13 09:39:08 -04:00
Adam Piontek ce03eaaf2d user settings templates updated, user management index template updated 2022-08-13 09:30:17 -04:00
Adam Piontek 3eff955672 progress on migrating to heex templates and font-icons 2022-08-13 07:32:36 -04:00
Adam Piontek d43daafdb7 updated Bamboo references to Swoosh; added runtime.exs config file 2022-08-13 06:39:14 -04:00
Adam Piontek 721ba53c15 updated deps & switched from Mix.Config to Config 2022-08-13 06:19:56 -04:00
172 changed files with 3244 additions and 18012 deletions

45
.dockerignore Normal file
View File

@ -0,0 +1,45 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json

3
.gitignore vendored
View File

@ -35,7 +35,7 @@ npm-debug.log
# Since we are building assets from assets/,
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
#/priv/static/
# Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control.
@ -50,3 +50,4 @@ npm-debug.log
# dev
TODO.md
NOTES.md

96
Dockerfile Normal file
View File

@ -0,0 +1,96 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
# Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20230109-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.14.3-erlang-25.2.1-debian-bullseye-20230109-slim
#
ARG ELIXIR_VERSION=1.14.3
ARG OTP_VERSION=25.2.1
ARG DEBIAN_VERSION=bullseye-20230109-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git curl && \
curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && \
apt-get install -y nodejs && apt-get clean && \
rm -f /var/lib/apt/lists/*_* && npm i -g npm
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# install node modules
RUN npm --prefix assets install
# compile assets
RUN mix assets.deploy
# Compile the release
RUN mix compile
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/shift73k ./
USER nobody
CMD ["/app/bin/server"]

155
README.md
View File

@ -4,87 +4,86 @@ Calendaring app for shift-worker shift tracking, with support for CSV export and
Written in Elixir & Phoenix LiveView, with Bootstrap v5.
## Production
To run in production, you'll need to provide several environment variable values:
```bash
MIX_ENV=prod \
PHX_SERVER=true \
TZ=America/New_York \
DB_SOCK=[postgres unix socket path] \
DB_NAME=[postgres db name] \
DB_USER=[postgres db user] \
DB_PASS=[postgres db user password] \
SECRET_KEY_BASE=[phoenix secret key base] \
PHX_HOST=[server fqdn (e.g., shift.73k.us)] \
PORT=4000 \
SMTP_RELAY=[smtp server] \
SMTP_PORT=[smtp port] \
SMTP_USER=[smtp username] \
SMTP_PASS=[smtp user password] \
MAIL_REPLY_TO=reply@73k.us \
MAIL_FROM_FRIENDLY=Shift73k \
MAIL_FROM_ADDR=shift73k@73k.us \
ALLOW_REG=[open for registration? true/false] \
iex -S mix phx.server
```
### Rebuilding assets for production
```bash
# rebuild static assets:
MIX_ENV=prod mix phx.digest.clean --all
rm -rf ./priv/static/*
npm --prefix assets run build
MIX_ENV=prod mix phx.digest
# then do a new commit and push...
```
## TODO
- [ ] Ability to edit shifts?
- [ ] Proper modal to delete shifts?
- [ ] Allow all-day items for notes, or require hours even for sick days?
- [ ] Implement proper shift/template/assign tests (views etc)
- [X] ~~*Proper modal to delete shifts?*~~ [2022-08-14]
- [X] ~~*move runtime config out of compile-time config files, to move towards supporting releases*~~ [2023-01-28]
- [ ] bootstrap dark mode?
- [ ] update tests, which are way out of date? Also I don't care?
## Deploying
## Deploying with docker
### New versions
The Dockerfile will enable building a new container. I do it all with docker compose, here's an example compose yml:
When improvements are made, we can update the deployed version like so:
```shell
cd /opt/shift73k
# update from master
/usr/bin/git pull 73k master
# fetch prod deps & compile
/usr/bin/mix deps.get --only prod
MIX_ENV=prod /usr/bin/mix compile
# perform any migrations
MIX_ENV=prod /usr/bin/mix ecto.migrate
# update node packages via package-lock.json
/usr/bin/npm --prefix /opt/shift73k/assets/ ci
# rebuild static assets:
rm -rf /opt/shift73k/priv/static/*
/usr/bin/npm --prefix /opt/shift73k/assets/ run deploy
MIX_ENV=prod /usr/bin/mix phx.digest
# rebuild release
MIX_ENV=prod /usr/bin/mix release --overwrite
# restart service
sudo /bin/systemctl restart shift73k.service
```yaml
version: '3.9'
services:
shift73k:
build:
context: ./shift73k # relative path from docker-compose.yml to shift73k repo
network: host
container_name: www-shift73k
restart: unless-stopped
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- /srv/dck/postgres/sock/postgres:/var/run/postgresql # if using unix socket
# env_file: ./shift73k.env # optionally, put your env vars in a separate file
environment:
- PHX_SERVER=true
- TZ=America/New_York
- DB_SOCK=/var/run/postgresql # if using unix socket instead of db url
- DB_NAME=[postgres db name] # if using unix socket instead of db url
- DB_USER=[postgres db user] # if using unix socket instead of db url
- DB_PASS=[postgres db user password] # if using unix socket instead of db url
- SECRET_KEY_BASE=[phoenix secret key base]
- PHX_HOST=[server fqdn (e.g., shift.73k.us)]
- PORT=4000
- SMTP_RELAY=[smtp server]
- SMTP_PORT=[smtp port]
- SMTP_USER=[smtp username]
- SMTP_PASS=[smtp user password]
- MAIL_REPLY_TO=reply@73k.us
- MAIL_FROM_FRIENDLY=Shift73k
- MAIL_FROM_ADDR=shift73k@73k.us
- ALLOW_REG=[open for registration? true/false]
ports:
- 4000:4000
```
### systemd unit:
```ini
[Unit]
Description=Shift73k service
After=local-fs.target network.target
[Service]
Type=simple
User=runuser
Group=runuser
WorkingDirectory=/opt/shift73k/_build/prod/rel/shift73k
ExecStart=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k start
ExecStop=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k stop
#EnvironmentFile=/etc/default/myApp.env
Environment=LANG=en_US.utf8
Environment=MIX_ENV=prod
#Environment=PORT=4000
LimitNOFILE=65535
UMask=0027
SyslogIdentifier=shift73k
Restart=always
[Install]
WantedBy=multi-user.target
```
### nginx config:
```conf
upstream phoenix {
server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}
server {
location / {
allow all;
# Proxy Headers
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Cluster-Client-Ip $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
# WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://phoenix;
}
}
```

View File

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

View File

@ -9,6 +9,7 @@
@import "bs-colors";
// Required || Configuration -- CONTINUED
@import "../node_modules/bootstrap/scss/maps";
@import "../node_modules/bootstrap/scss/mixins";
@import "../node_modules/bootstrap/scss/utilities";

View File

@ -9,3 +9,6 @@
// @import "../node_modules/@fontsource/lato/700-italic.css"; /* bold | italic */
// @import "../node_modules/@fontsource/lato/900.css"; /* black | normal */
// @import "../node_modules/@fontsource/lato/900-italic.css"; /* black | italic */
/* Bootstrap Icons Font */
@import "../node_modules/bootstrap-icons/font/bootstrap-icons.css";

View File

@ -32,6 +32,10 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-light-color;
}
}
&:focus {
box-shadow: $navbar-light-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
// var(--bs-navbar-toggler-focus-width);
}
}
}
}
@ -51,6 +55,9 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-dark-color;
}
}
&:focus {
box-shadow: $navbar-dark-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
}
}
}
}

View File

@ -1,21 +0,0 @@
/*
SVG ICON SYSTEM
per https://blog.prototypr.io/align-svg-icons-to-text-and-say-goodbye-to-font-icons-d44b3d7b26b4
*/
.icon {
display: inline-flex;
align-self: center;
}
.icon svg,
.icon img {
height: 1em;
width: 1em;
fill: currentColor;
}
.icon.baseline svg,
.icon img {
top: 0.125em;
position: relative;
}

View File

@ -4,9 +4,6 @@
/* Load Bootstrap v5 and customizations */
@import "bs-load";
/*SVG ICON SYSTEM*/
@import "svg-icons";
/* LiveView specific CSS */
@import "phx-liveview";
@ -43,7 +40,7 @@
/* style icon */
.inner-addon > .icon {
position: absolute;
padding: 0.5625rem 0.5rem;
padding: 0.25rem 0.5rem;
pointer-events: none;
}
@ -68,3 +65,11 @@
.shift-description p:last-child {
margin-bottom: 0;
}
/*
fix readonly form background
*/
.form-control[readonly] {
background-color: $input-disabled-bg;
}

View File

@ -1,12 +1,14 @@
const togglerBtn = document.getElementById("navbarSupportedContentToggler");
const navbarContent = document.getElementById("navbarSupportedContent");
const togglerBtn = document.getElementById('navbarSupportedContentToggler');
const navbarContent = document.getElementById('navbarSupportedContent');
navbarContent.addEventListener("show.bs.collapse", () => {
console.log("opening navbar content");
togglerBtn.classList.toggle("is-active");
});
navbarContent.addEventListener("hide.bs.collapse", () => {
console.log("closing navbar content");
togglerBtn.classList.toggle("is-active");
});
if (navbarContent != null) {
navbarContent.addEventListener('show.bs.collapse', () => {
console.log('opening navbar content');
togglerBtn.classList.toggle('is-active');
});
navbarContent.addEventListener('hide.bs.collapse', () => {
console.log('closing navbar content');
togglerBtn.classList.toggle('is-active');
});
}

View File

@ -1,84 +1,24 @@
// We need to import the CSS so that webpack will load it.
// The MiniCssExtractPlugin is used to separate it out into
// its own CSS file.
import "../css/app.scss";
// We import the main SCSS file, which performs all other SCSS imports,
// and which vite.js will preprocess with sass.
import '../css/app.scss'
// Import icons for sprite-loader
// navbar brand icon
import "../node_modules/bootstrap-icons/icons/calendar2-week.svg"; // brand
// menus etc
import "../node_modules/bootstrap-icons/icons/person-circle.svg"; // accounts menu
import "../node_modules/bootstrap-icons/icons/person-plus.svg"; // new user / register
import "../node_modules/bootstrap-icons/icons/door-open.svg"; // log in
import "../node_modules/bootstrap-icons/icons/door-closed.svg"; // log out
import "../node_modules/bootstrap-icons/icons/sliders.svg"; // new user / register
// forms etc
import "../node_modules/bootstrap-icons/icons/at.svg"; // email field
import "../node_modules/bootstrap-icons/icons/key.svg"; // new password field
import "../node_modules/bootstrap-icons/icons/key-fill.svg"; // pw confirm field
import "../node_modules/bootstrap-icons/icons/lock.svg"; // current pw field
import "../node_modules/bootstrap-icons/icons/shield.svg"; // role
// live tables
import "../node_modules/bootstrap-icons/icons/arrow-down-up.svg"; // sort
import "../node_modules/bootstrap-icons/icons/funnel.svg"; // filter
import "../node_modules/bootstrap-icons/icons/x-circle-fill.svg"; // clear filter
import "../node_modules/bootstrap-icons/icons/sort-down-alt.svg";
import "../node_modules/bootstrap-icons/icons/sort-up-alt.svg";
import "../node_modules/bootstrap-icons/icons/chevron-left.svg";
import "../node_modules/bootstrap-icons/icons/chevron-right.svg";
import "../node_modules/bootstrap-icons/icons/pencil.svg";
import "../node_modules/bootstrap-icons/icons/trash.svg";
// page headers
import "../node_modules/bootstrap-icons/icons/shield-lock.svg"; // reset password
import "../node_modules/bootstrap-icons/icons/arrow-repeat.svg"; // resend confirmation
import "../node_modules/@mdi/svg/svg/head-question-outline.svg"; // forgot password
import "../node_modules/bootstrap-icons/icons/people.svg"; // users management
// calendar/event icons
import "../node_modules/bootstrap-icons/icons/calendar2.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-plus.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-date.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-event.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-range.svg";
import "../node_modules/bootstrap-icons/icons/clock-history.svg"; // shift template
import "../node_modules/bootstrap-icons/icons/tag.svg";
import "../node_modules/bootstrap-icons/icons/hourglass.svg";
import "../node_modules/bootstrap-icons/icons/map.svg";
import "../node_modules/bootstrap-icons/icons/geo.svg";
import "../node_modules/bootstrap-icons/icons/justify-left.svg";
import "../node_modules/bootstrap-icons/icons/plus-circle-dotted.svg";
import "../node_modules/bootstrap-icons/icons/clipboard-plus.svg";
import "../node_modules/bootstrap-icons/icons/star.svg";
import "../node_modules/bootstrap-icons/icons/star-fill.svg";
import "../node_modules/bootstrap-icons/icons/binoculars.svg";
import "../node_modules/bootstrap-icons/icons/binoculars-fill.svg";
import "../node_modules/bootstrap-icons/icons/eraser.svg";
import "../node_modules/bootstrap-icons/icons/save.svg";
import "../node_modules/bootstrap-icons/icons/asterisk.svg";
import "../node_modules/bootstrap-icons/icons/card-list.svg";
import "../node_modules/bootstrap-icons/icons/file-earmark-spreadsheet.svg";
import "../node_modules/bootstrap-icons/icons/box-arrow-in-left.svg";
import "../node_modules/bootstrap-icons/icons/link.svg";
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import 'phoenix_html'
// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
// in "webpack.config.js".
//
// Import deps with the dep name or local files with a relative path, for example:
//
// import {Socket} from "phoenix"
// import socket from "./socket"
//
import "phoenix_html";
// import Socket for Phoenix Channels
import { Socket } from "phoenix";
// import topbar for load progress in live reloading / liveview
import topbar from "topbar";
// import LiveSocket for LiveView
import { LiveSocket } from "phoenix_live_view";
// Bootstrap v5 js imports
import "bootstrap/js/dist/alert";
import "bootstrap/js/dist/collapse";
import "bootstrap/js/dist/dropdown";
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/dropdown';
// Bootstrap helpers
import "./_hamburger-helper";
import './_hamburger-helper';
import "./_form-validity";
// Bootstrap-liveview helpers
import { AlertRemover } from "./_alert-remover";

17650
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +1,30 @@
{
"repository": {},
"description": " ",
"license": "MIT",
"name": "vanilla",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"@fontsource/lato": "^4.2.2",
"@mdi/svg": "^5.9.55",
"@popperjs/core": "^2.9.2",
"bootstrap": "^5.0.0-beta3",
"bootstrap-icons": "^1.4.1",
"hamburgers": "^1.1.3",
"heroicons": "^1.0.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.x"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@babel/core": "^7.x",
"@babel/preset-env": "^7.x",
"autoprefixer": "^10.2.5",
"babel-loader": "^8.x",
"copy-webpack-plugin": "^8.x",
"css-loader": "^5.x",
"css-minimizer-webpack-plugin": "^1.x",
"file-loader": "^6.2.0",
"glob-all": "^3.2.1",
"mini-css-extract-plugin": "^1.x",
"postcss": "^8.2.9",
"postcss-loader": "^5.2.0",
"postcss-scss": "^3.0.5",
"purgecss-webpack-plugin": "^4.0.3",
"sass": "^1.x",
"sass-loader": "^11.x",
"svg-sprite-loader": "^6.x",
"webpack": "^5.x",
"webpack-cli": "^4.x"
"@fullhuman/postcss-purgecss": "^5.0.0",
"@types/node": "^18.6.5",
"@types/phoenix": "^1.5.4",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",
"phoenix_live_view": "^0.18.11",
"sass": "^1.54.3",
"vite": "^4.0.4"
},
"dependencies": {
"@fontsource/lato": "^4.5.9",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"hamburgers": "^1.2.1",
"phoenix": "^1.6.11",
"phoenix_html": "^3.2.0",
"topbar": "^1.x"
}
}

24
assets/postcss.config.cjs Normal file
View File

@ -0,0 +1,24 @@
const autoprefixer = require('autoprefixer')
const purgecss = require('@fullhuman/postcss-purgecss')
const cssnano = require('cssnano')
module.exports = {
plugins: [
autoprefixer,
purgecss({
content: [
"./js/**/*.js",
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex"
],
safelist: {
deep: [
/^phx/
]
},
}),
cssnano({
preset: 'default',
})
],
};

View File

@ -1,5 +0,0 @@
module.exports = {
parser: require("postcss-scss"),
plugins: [require("autoprefixer")],
};

37
assets/vite.config.js Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from "vite";
export default defineConfig(({ command }) => {
const isDev = command !== "build";
if (isDev) {
// Terminate the watcher when Phoenix quits
process.stdin.on("close", () => {
process.exit(0);
});
process.stdin.resume();
}
return {
server: {
port: 3000
},
publicDir: "static",
build: {
target: "esnext", // build for recent browsers
outDir: "../priv/static", // emit assets to priv/static
emptyOutDir: true,
sourcemap: isDev, // enable source map in dev build
manifest: false, // do not generate manifest.json
rollupOptions: {
input: {
app: "./js/app.js"
},
output: {
entryFileNames: "assets/[name].js", // remove hash
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]"
}
}
}
};
});

View File

@ -1,105 +0,0 @@
const path = require("path");
const glob = require("glob-all");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const SpriteLoaderPlugin = require("svg-sprite-loader/plugin");
const PurgecssPlugin = require("purgecss-webpack-plugin");
module.exports = (env, options) => {
const devMode = options.mode !== "production";
return {
entry: {
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
},
output: {
path: path.resolve(__dirname, "../priv/static/js"),
filename: "[name].js",
publicPath: "/js/",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.[s]?css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"sass-loader",
"postcss-loader",
],
},
{
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: "file-loader",
options: {
esModule: false,
name: "[name].[ext]",
outputPath: "../fonts",
},
},
],
},
{
test: /\.svg$/,
loader: "svg-sprite-loader",
options: {
extract: true,
spriteFilename: "icons.svg",
publicPath: "../images/",
symbolId: (filePath) => {
if (filePath.includes("bootstrap-icons")) {
return `bi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("@mdi")) {
return `mdi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("heroicons")) {
if (filePath.includes("outline")) {
return `hio-${path.basename(filePath).slice(0, -4)}`;
} else {
return `his-${path.basename(filePath).slice(0, -4)}`;
}
} else {
return `${path.basename(filePath).slice(0, -4)}`;
}
},
},
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: "../css/app.css" }),
new SpriteLoaderPlugin({ plainSprite: true }),
new CopyWebpackPlugin({
patterns: [{ from: "static/", to: "../" }],
}),
].concat(
devMode
? []
: [
new PurgecssPlugin({
paths: glob.sync([
"../**/*.html.leex",
"../**/*.html.eex",
"../**/views/**/*.ex",
"../**/live/**/*.ex",
"./js/**/*.js",
]),
safelist: [/phx/, /topbar/],
}),
]
),
optimization: {
minimizer: ["...", new CssMinimizerPlugin()],
},
devtool: devMode ? "source-map" : undefined,
};
};

View File

@ -5,19 +5,13 @@
# is restricted to this project.
# General application configuration
use Mix.Config
import Config
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
config :shift73k,
ecto_repos: [Shift73k.Repo]
# Custom application global variables
config :shift73k, :app_global_vars,
time_zone: "America/New_York",
mailer_reply_to: "reply_to@example.com",
mailer_from: "app_name@example.com"
# Configures the endpoint
config :shift73k, Shift73kWeb.Endpoint,
url: [host: "localhost"],
@ -26,6 +20,18 @@ config :shift73k, Shift73kWeb.Endpoint,
pubsub_server: Shift73k.PubSub,
live_view: [signing_salt: "2D4GC4ac"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Local
# Swoosh API client is needed for adapters other than SMTP.
config :swoosh, :api_client, false
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",

View File

@ -1,11 +1,12 @@
use Mix.Config
import Config
# Configure your database
config :shift73k, Shift73k.Repo,
username: "postgres",
password: "postgres",
socket_dir: "/srv/dck/postgres/sock/postgres",
database: "shift73k_dev",
hostname: "localhost",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
@ -22,11 +23,7 @@ config :shift73k, Shift73kWeb.Endpoint,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
"node_modules/vite/bin/vite.js",
cd: Path.expand("../assets", __DIR__)
]
]
@ -75,6 +72,3 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
# Import secret config
import_config "dev.secret.exs"

View File

@ -1,4 +1,4 @@
use Mix.Config
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
@ -49,7 +49,3 @@ config :logger, level: :info
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# Finally import the config/prod.secret.exs which loads secrets
# and configuration from environment variables.
import_config "prod.secret.exs"

133
config/runtime.exs Normal file
View File

@ -0,0 +1,133 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# 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/shift73k start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :shift73k, Shift73kWeb.Endpoint, server: true
end
if config_env() == :prod do
database_sock = System.get_env("DB_SOCK") || :false
database_name = System.get_env("DB_NAME") || :false
database_user = System.get_env("DB_USER") || :false
database_pass = System.get_env("DB_PASS") || :false
database_url = System.get_env("DATABASE_URL") || :false
if (!database_sock && !database_url) do
raise """
environment variable DATABASE_URL is missing.
can also configure with unix socket by providing
DB_SOCK, DB_NAME, DB_USER, and DB_PASS values.
"""
end
if (database_sock) do
if (!database_name) do
raise """
environment variable DB_NAME is missing.
"""
end
if (!database_user) do
raise """
environment variable DB_USER is missing.
"""
end
if (!database_pass) do
raise """
environment variable DB_PASS is missing.
"""
end
end
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
if (database_sock) do
config :shift73k, Shift73k.Repo,
username: database_user,
password: database_pass,
socket_dir: database_sock,
database: database_name,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
else
config :shift73k, Shift73k.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
end
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :shift73k, Shift73kWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {0, 0, 0, 0}, port: port],
secret_key_base: secret_key_base
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :shift73k, Shift73k.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
# Swoosh mailer config
config :shift73k, Shift73k.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_RELAY"),
port: System.get_env("SMTP_PORT"),
username: System.get_env("SMTP_USER"),
password: System.get_env("SMTP_PASS"),
ssl: false,
tls: :always,
auth: :always,
allowed_tls_versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"],
retries: 1,
no_mx_lookups: false
config :shift73k, :app_global_vars,
time_zone: System.get_env("TZ") || "America/New_York",
mailer_reply_to: System.get_env("MAIL_REPLY_TO") || "reply_to@example.com",
mailer_from: {
System.get_env("MAIL_FROM_FRIENDLY") || "Shift73k",
System.get_env("MAIL_FROM_ADDR") || "app_name@example.com"
},
allow_registration: System.get_env("ALLOW_REG") || :true
end

View File

@ -1,4 +1,4 @@
use Mix.Config
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
@ -13,7 +13,8 @@ config :shift73k, Shift73k.Repo,
password: "postgres",
database: "shift73k_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
# We don't run a server during test. If one is required,
# you can enable the server option below.
@ -24,8 +25,8 @@ config :shift73k, Shift73kWeb.Endpoint,
# Print only warnings and errors during test
config :logger, level: :warn
# Bamboo test mailer config
config :shift73k, Shift73k.Mailer, adapter: Bamboo.TestAdapter
# Swoosh test mailer config
config :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Test
# Import secret config
import_config "test.secret.exs"
# import_config "test.secret.exs"

7
entrypoint.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/ash
export MIX_ENV="prod"
cd /app
mix ecto.migrate
exec mix phx.server

View File

@ -7,12 +7,32 @@ defmodule Shift73k do
if it comes from the database, an external API or others.
"""
@app_vars Application.compile_env(:shift73k, :app_global_vars, time_zone: "America/New_York")
@app_time_zone @app_vars[:time_zone]
@weekdays [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
def app_time_zone, do: @app_time_zone
defp get_app_config_env do
Application.get_env(:shift73k, :app_global_vars,
time_zone: "America/New_York",
allow_registration: :true,
mailer_reply_to: "admin@example.com",
mailer_from: {"Shift73k", "shift73k@example.com"}
)
end
def weekdays, do: @weekdays
def get_app_time_zone, do:
get_app_config_env() |> Keyword.fetch!(:time_zone) |> IO.inspect(label: "time_zone", pretty: :true)
def get_app_mailer_from, do:
get_app_config_env() |> Keyword.fetch!(:mailer_from) |> IO.inspect(label: "mailer_from", pretty: :true)
def get_app_mailer_reply_to, do:
get_app_config_env() |> Keyword.fetch!(:mailer_reply_to) |> IO.inspect(label: "mailer_reply_to", pretty: :true)
def get_app_allow_reg, do:
get_app_config_env() |> Keyword.fetch!(:allow_registration) |> get_app_allow_reg()
|> IO.inspect(label: "allow_registration", pretty: :true)
def get_app_allow_reg("false"), do: :false
def get_app_allow_reg(:false), do: :false
def get_app_allow_reg(_not_false), do: :true
end

View File

@ -108,6 +108,13 @@ defmodule Shift73k.Accounts do
"""
def register_user(attrs) do
# If attrs has atom keys, convert to string
# If attrs don't include role, put default role
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put_new("role", registration_role())
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()

View File

@ -20,19 +20,19 @@ defmodule Shift73k.Accounts.User do
@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(:confirmed_at, :naive_datetime)
field(:calendar_slug, :string, default: Ecto.UUID.generate())
field :email, :string
field :password, :string, virtual: true
field :hashed_password, :string
field :confirmed_at, :naive_datetime
field :calendar_slug, :string, default: Ecto.UUID.generate()
field(:role, Ecto.Enum, values: Keyword.keys(@roles), default: :user)
field(:week_start_at, Ecto.Enum, values: weekdays(), default: :monday)
field :role, Ecto.Enum, values: Keyword.keys(@roles), default: :user
field :week_start_at, Ecto.Enum, values: weekdays(), default: :monday
has_many(:shift_templates, ShiftTemplate)
belongs_to(:fave_shift_template, ShiftTemplate)
has_many :shift_templates, ShiftTemplate
belongs_to :fave_shift_template, ShiftTemplate
has_many(:shifts, Shift)
has_many :shifts, Shift
timestamps()
end

View File

@ -2,12 +2,19 @@ defmodule Shift73k.Accounts.UserNotifier do
alias Shift73k.Mailer
alias Shift73k.Mailer.UserEmail
def deliver(user_email, subject, body) do
%Swoosh.Email{} = email = UserEmail.compose(user_email, subject, body)
case Mailer.deliver(email) do
{:ok, msg} -> {:ok, msg, email}
err -> err
end
end
@doc """
Deliver instructions to confirm account.
"""
def deliver_confirmation_instructions(user, url) do
user
|> UserEmail.compose("Confirm Your Account", """
deliver(user.email, "Confirmation instructions", """
==============================
@ -21,15 +28,13 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
@doc """
Deliver instructions to reset a user password.
"""
def deliver_reset_password_instructions(user, url) do
user
|> UserEmail.compose("Reset Your Password", """
deliver(user.email, "Reset password instructions", """
==============================
@ -43,15 +48,13 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
user
|> UserEmail.compose("Change Your E-mail", """
deliver(user.email, "Update email instructions", """
==============================
@ -65,6 +68,5 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
end

View File

@ -15,10 +15,10 @@ defmodule Shift73k.Accounts.UserToken do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field(:token, :binary)
field(:context, :string)
field(:sent_to, :string)
belongs_to(:user, Shift73k.Accounts.User)
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Shift73k.Accounts.User
timestamps(updated_at: false)
end

View File

@ -1,3 +1,3 @@
defmodule Shift73k.Mailer do
use Bamboo.Mailer, otp_app: :shift73k
use Swoosh.Mailer, otp_app: :shift73k
end

View File

@ -1,16 +1,13 @@
defmodule Shift73k.Mailer.UserEmail do
import Bamboo.Email
import Swoosh.Email
import Shift73k, only: [get_app_mailer_from: 0, get_app_mailer_reply_to: 0]
@mailer_vars Application.compile_env(:shift73k, :app_global_vars,
mailer_reply_to: "admin@example.com",
mailer_from: {"Shift73k", "shift73k@example.com"}
)
def compose(user, subject, body_text) do
new_email()
|> from(@mailer_vars[:mailer_from])
|> to(user.email)
|> put_header("Reply-To", @mailer_vars[:mailer_reply_to])
def compose(user_email, subject, body_text) do
new()
|> from(get_app_mailer_from())
|> to(user_email)
|> header("Reply-To", get_app_mailer_reply_to())
|> subject(subject)
|> text_body(body_text)
end

28
lib/shift73k/release.ex Normal file
View File

@ -0,0 +1,28 @@
defmodule Shift73k.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :shift73k
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View File

@ -86,8 +86,12 @@ defmodule Shift73k.Shifts do
** (Ecto.NoResultsError)
"""
def get_shift!(nil), do: nil
def get_shift!(id), do: Repo.get!(Shift, id)
def get_shift(nil), do: nil
def get_shift(id), do: Repo.get(Shift, id)
@doc """
Creates a shift.

View File

@ -13,7 +13,7 @@ defmodule Shift73k.Shifts.Shift do
field :time_start, :time
field :time_end, :time
belongs_to(:user, Shift73k.Accounts.User)
belongs_to :user, Shift73k.Accounts.User
timestamps()
end

View File

@ -1,7 +1,7 @@
defmodule Shift73k.Shifts.Templates.ShiftTemplate do
use Ecto.Schema
import Ecto.Changeset
import Shift73k, only: [app_time_zone: 0]
import Shift73k, only: [get_app_time_zone: 0]
alias Shift73k.Shifts
alias Shift73k.Shifts.Templates.ShiftTemplate
@ -12,12 +12,12 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
field :subject, :string
field :description, :string
field :location, :string
field :time_zone, :string, default: app_time_zone()
field :time_zone, :string, default: get_app_time_zone()
field :time_start, :time, default: ~T[09:00:00]
field :time_end, :time, default: ~T[17:00:00]
belongs_to(:user, Shift73k.Accounts.User)
has_one(:is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id)
belongs_to :user, Shift73k.Accounts.User
has_one :is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id
timestamps()
end
@ -57,6 +57,7 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
[]
end
end)
|> validate_not_nil([:time_zone])
|> validate_inclusion(:time_zone, Tzdata.zone_list(),
message: "must be a valid IANA tz database time zone"
)
@ -72,4 +73,14 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
|> Map.from_struct()
|> Map.drop([:__meta__, :id, :inserted_at, :updated_at, :user, :is_fave_of_user])
end
def validate_not_nil(changeset, fields) do
Enum.reduce(fields, changeset, fn field, changeset ->
if get_field(changeset, field) == nil do
add_error(changeset, field, "nil")
else
changeset
end
end)
end
end

View File

@ -46,7 +46,7 @@ defmodule Shift73kWeb do
def live_view do
quote do
use Phoenix.LiveView,
layout: {Shift73kWeb.LayoutView, "live.html"}
layout: {Shift73kWeb.LayoutView, :live}
unquote(view_helpers())
import Shift73kWeb.LiveHelpers
@ -100,14 +100,11 @@ defmodule Shift73kWeb do
use Phoenix.HTML
# Import LiveView helpers (live_render, live_component, live_patch, etc)
import Phoenix.LiveView.Helpers
import Phoenix.Component
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
# Import SVG Icon helper
import Shift73kWeb.IconHelpers
import Shift73kWeb.ErrorHelpers
import Shift73kWeb.Gettext
alias Shift73kWeb.Router.Helpers, as: Routes

View File

@ -10,51 +10,50 @@ defmodule Shift73kWeb.Endpoint do
signing_salt: "9CKxo0VJ"
]
socket("/socket", Shift73kWeb.UserSocket,
socket "/socket", Shift73kWeb.UserSocket,
websocket: true,
longpoll: false
)
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
#
# file list generated by simple ls -1 assets/static/ - then copy/paste here
plug(Plug.Static,
# file list generated by simple ls -1 priv/static/ - then copy/paste here
plug Plug.Static,
at: "/",
from: :shift73k,
gzip: (Mix.env() not in [:dev, :test]),
only: "priv/static" |> Path.expand() |> File.ls!()
)
gzip: false
# For using vite.js in dev, we need to instruct Phoenix to serve files at assets/src over the usual endpoint. This is only necessary in development.
if Mix.env() == :dev do
plug Plug.Static,
at: "/",
from: "assets",
gzip: false
end
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k)
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k
end
plug(Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug(Plug.RequestId)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
plug(Plug.Parsers,
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
)
plug(Plug.MethodOverride)
plug(Plug.Head)
plug(Plug.Session, @session_options)
plug(Shift73kWeb.Router)
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug Shift73kWeb.Router
end

View File

@ -1,6 +1,6 @@
defmodule Shift73kWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
import Phoenix.Component
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@ -19,27 +19,6 @@ defmodule Shift73kWeb.LiveHelpers do
"""
def live_okreply(socket), do: {:ok, socket}
@doc """
Renders a component inside the `Shift73kWeb.ModalComponent` component.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
action: @live_action,
property: @property,
return_to: Routes.property_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
modal_opts = [id: :modal, component: component, opts: opts]
# dirty little workaround for elixir complaining about socket being unused
_socket = socket
live_component(socket, Shift73kWeb.ModalComponent, modal_opts)
end
@doc """
Loads default assigns for liveviews
"""

View File

@ -3,12 +3,12 @@ defmodule Shift73kWeb.ModalComponent do
@impl true
def render(assigns) do
~L"""
<div id="<%= @id %>" class="modal fade"
~H"""
<div id={@id} class="modal fade"
phx-hook="BsModal"
phx-window-keydown="hide"
phx-key="escape"
phx-target="#<%= @id %>"
phx-target={"#" <> to_string(@id)}
phx-page-loading>
<div class="modal-dialog modal-dialog-centered">
@ -16,10 +16,10 @@ defmodule Shift73kWeb.ModalComponent do
<div class="modal-header">
<h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5>
<button type="button" class="btn-close" phx-click="hide" phx-target="<%= @myself %>" aria-label="Close"></button>
<button type="button" class="btn-close" phx-click="hide" phx-target={@myself} aria-label="Close"></button>
</div>
<%= live_component @socket, @component, Keyword.put(@opts, :modal_id, @id) %>
<%= live_component @component, Keyword.put(@opts, :modal_id, @id) %>
</div>
</div>

View File

@ -0,0 +1,35 @@
<div>
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>
</div>

View File

@ -1,31 +0,0 @@
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>

View File

@ -1,5 +1,6 @@
defmodule Shift73kWeb.ShiftAssignLive.Index do
use Shift73kWeb, :live_view
import Shift73k, only: [get_app_time_zone: 0]
alias Shift73k.Repo
alias Shift73k.Shifts

View File

@ -1,16 +1,20 @@
<%= if @delete_days_shifts do %>
<%= live_modal @socket, Shift73kWeb.ShiftAssignLive.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftAssignLive.DeleteComponent}
opts={[
id: "delete-days-shifts-#{@current_user.id}",
title: "Delete Shifts From Selected Days",
delete_days_shifts: @delete_days_shifts,
current_user: @current_user
%>
]}
/>
<% end %>
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-calendar2-plus", [class: "icon baseline"] %>
Schedule Shifts
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts
</h2>
<div class="row justify-content-center mt-4">
@ -24,14 +28,19 @@
%>
<% end %>
<button type="button" class="ms-2 btn btn-primary text-nowrap <%= if @show_template_btn_active, do: "active" %>" id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
<%= icon_div @socket, (@show_template_btn_active && "bi-binoculars-fill" || "bi-binoculars"), [class: "icon baseline"] %>
<% details_button_class = "ms-2 btn btn-primary text-nowrap"
details_button_class = if @show_template_btn_active, do: "#{details_button_class} active", else: details_button_class %>
<button type="button" class={details_button_class} id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
<i class={@show_template_btn_active && "bi bi-binoculars-fill me-sm-1" || "bi bi-binoculars me-sm-1"}></i>
<span class="d-none d-sm-inline">Details</span>
</button>
</div>
<div class="col-12 col-lg-9 col-xl-8 col-xxl-7 <%= @show_template_details && "collapse show" || "collapse" %>" id="#templateDetailsCol" phx-hook="BsCollapse">
<% template_details_div_class = "col-12 col-lg-9 col-xl-8 col-xxl-7 collapse"
template_details_div_class = if @show_template_details, do: "#{template_details_div_class} show", else: template_details_div_class %>
<div class={template_details_div_class} id="#templateDetailsCol" phx-hook="BsCollapse">
<div class="card mt-4">
<div class="card-body">
@ -41,8 +50,8 @@
<div class="col-12 col-md-6">
<%= label stf, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :subject) %>">
<%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :subject)}>
<i class="bi bi-tag icon is-left text-muted fs-5"></i>
<%= text_input stf, :subject,
value: input_value(stf, :subject),
class: input_class(stf, :subject, "form-control"),
@ -57,25 +66,27 @@
<div class="col-12 col-md-6 mb-3">
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<div class="col-6" phx-feedback-for={input_id(stf, :time_start)}>
<%= label stf, :time_start, "Start", class: "form-label" %>
<%= time_input stf, :time_start,
precision: :minute,
value: input_value(stf, :time_start),
class: input_class(stf, :time_start, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_start)
aria_describedby: error_ids(stf, :time_start),
required: true
%>
</div>
<div class="col-6" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<div class="col-6" phx-feedback-for={input_id(stf, :time_end)}>
<%= label stf, :time_end, "End", class: "form-label" %>
<%= time_input stf, :time_end,
precision: :minute,
value: input_value(stf, :time_end),
class: input_class(stf, :time_end, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_end)
aria_describedby: error_ids(stf, :time_end),
required: true
%>
</div>
@ -83,18 +94,18 @@
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_start)}>
<%= error_tag stf, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_end)}>
<%= error_tag stf, :time_end %>
</div>
</div>
<div class="col-12 col-md-6">
<%= label stf, :location, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :location) %>">
<%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :location)}>
<i class="bi bi-geo icon is-left text-muted fs-5"></i>
<%= text_input stf, :location,
value: input_value(stf, :location),
class: input_class(stf, :location, "form-control"),
@ -108,18 +119,19 @@
<div class="col-12 col-md-6">
<%= label stf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for="<%= input_id(stf, :time_zone) %>">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for={input_id(stf, :time_zone)}>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input stf, :time_zone,
value: input_value(stf, :time_zone),
class: input_class(stf, :time_zone, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
phx_debounce: 250,
list: "tz_list"
list: "tz_list",
placeholder: "Default: #{get_app_time_zone()}"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<option value={tz_name}></option>
<% end %>
end
</datalist>
@ -132,7 +144,7 @@
<div class="col-12">
<%= label stf, :description, class: "form-label" %>
<div phx-feedback-for="<%= input_id(stf, :description) %>">
<div phx-feedback-for={input_id(stf, :description)}>
<%= textarea stf, :description,
value: input_value(stf, :description),
@ -174,17 +186,17 @@
<%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3>
<div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}>
<i class="bi bi-asterisk me-sm-1"></i>
<span class="d-none d-sm-inline">Today</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<i class="bi bi-chevron-left me-sm-1"></i>
<span class="d-none d-sm-inline">Prev</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span>
<%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
<i class="bi bi-chevron-right ms-sm-1"></i>
</button>
</div>
</div>
@ -204,7 +216,7 @@
<%= for week <- @week_rows do %>
<tr>
<%= for day <- week do %>
<td class="<%= day_color(day, @current_date, @cursor_date, @selected_days) %>" phx-click="select-day" phx-value-day="<%= day %>">
<td class={day_color(day, @current_date, @cursor_date, @selected_days)} phx-click="select-day" phx-value-day={day}>
<%= Calendar.strftime(day, "%d") %><%= if day.month != @cursor_date.month, do: "-#{Calendar.strftime(day, "%b")}" %>
@ -236,19 +248,16 @@
<div class="row justify-content-center justify-content-lg-end my-5">
<div class="col-12 col-sm-10 col-md-8 col-lg-auto d-flex flex-column-reverse flex-lg-row">
<button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete shifts from selected days
<button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}>
<i class="bi bi-trash me-1"></i> Delete shifts from selected days
</button>
<button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<%= icon_div @socket, "bi-eraser", [class: "icon baseline"] %>
De-select all selected
<button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}>
<i class="bi bi-eraser me-1"></i> De-select all selected
</button>
<button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" <%= if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: "disabled" %>>
<%= icon_div @socket, "bi-save", [class: "icon baseline"] %>
Assign shifts to selected days
<button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" disabled={if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: :true, else: :false}>
<i class="bi bi-save me-1"></i> Assign shifts to selected days
</button>
</div>

View File

@ -2,10 +2,8 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @socket, "bi-box-arrow-in-left", [class: "icon baseline"] %>
Import Shifts
<i class="bi bi-box-arrow-in-left me-1"></i> Import Shifts
</h2>
<p class="lead">If you have an iCal/ics formatted calendar hosted elsewhere, provide its URL here to import its events.</p>
<div class="row justify-content-center">
@ -17,7 +15,7 @@
<% valid_class = @url_validated && "is-valid" || "" %>
<%= label iimf, :ics_url, "iCal/ics URL", class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-link", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-link icon is-left text-muted fs-5"></i>
<%= url_input iimf, :ics_url,
class: show_url_error && "form-control is-invalid" || "form-control #{valid_class}",
phx_debounce: 500,
@ -33,9 +31,9 @@
<%= label iimf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input iimf, :time_zone,
value: Shift73k.app_time_zone(),
value: Shift73k.get_app_time_zone(),
class: @tz_valid && "form-control" || "form-control is-invalid",
phx_debounce: 250,
aria_describedby: "ics-import-tz-error",
@ -43,7 +41,7 @@
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<option value={tz_name}></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>

View File

@ -0,0 +1,46 @@
defmodule Shift73kWeb.ShiftLive.DeleteComponent do
use Shift73kWeb, :live_component
alias Shift73k.Shifts
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> live_okreply()
end
@impl true
def handle_event("confirm", %{"id" => id, "subject" => subject, "datetime" => datetime}, socket) do
shift = Shifts.get_shift(id)
if (shift) do
shift
|> Shifts.delete_shift()
|> case do
{:ok, _} ->
flash = {:info, "Shift deleted successfully: \"#{subject}\""}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, _} ->
handle_error(socket, subject, datetime)
end
end
end
defp handle_error(socket, subject, datetime) do
flash =
{:error,
"Some error trying to delete shift \"#{subject} (#{datetime})\". Possibly already deleted? Reloading list..."}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
end
end

View File

@ -0,0 +1,22 @@
<div>
<div class="modal-body">
<% shift_datetime = "#{Calendar.strftime(@delete_shift.date, "%A, %b %-d")}, #{format_shift_time(@delete_shift.time_start)} — #{format_shift_time(@delete_shift.time_end)}" %>
Are you sure you want to delete "<%= @delete_shift.subject %> (<%= shift_datetime %>)?"
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift.id,
phx_value_subject: @delete_shift.subject,
phx_value_datetime: shift_datetime %>
</div>
</div>

View File

@ -22,6 +22,7 @@ defmodule Shift73kWeb.ShiftLive.Index do
socket
|> init_today(Date.utc_today())
|> update_agenda()
|> assign_modal_close_handlers()
|> assign(:delete_shift, nil)
|> apply_action(socket.assigns.live_action, params)
|> live_noreply()
@ -33,6 +34,11 @@ defmodule Shift73kWeb.ShiftLive.Index do
end
end
defp assign_modal_close_handlers(socket) do
to = Routes.shift_index_path(socket, :index)
assign(socket, modal_return_to: to, modal_close_action: :return)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "My Shifts")
@ -76,6 +82,14 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> assign_known_shifts()
end
@impl true
def handle_event("delete-modal", %{"id" => id}, socket) do
socket
|> assign(:modal_close_action, :delete_shift)
|> assign(:delete_shift, Shifts.get_shift!(id))
|> live_noreply()
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
shift = Shifts.get_shift!(id)
@ -94,6 +108,28 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> live_noreply()
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: :return}} = socket) do
socket
|> copy_flash()
|> push_patch(to: socket.assigns.modal_return_to)
|> live_noreply()
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: assign_key}} = socket) do
socket
|> assign(assign_key, nil)
|> assign_modal_close_handlers()
|> assign_known_shifts()
|> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp new_nav_cursor("now", _cursor_date), do: Date.utc_today()
defp new_nav_cursor(nav, cursor_date) do

View File

@ -1,40 +1,48 @@
<%= if @delete_shift do %>
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftLive.DeleteComponent}
opts={[
id: @delete_shift.id,
title: "Delete Shift Template",
delete_shift: @delete_shift
]}
/>
<% end %>
<div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10">
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-card-list", [class: "icon baseline"] %>
My Shifts
<i class="bi bi-card-list me-1"></i> My Shifts
</h2>
<div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10">
<%# month navigation %>
<div class="d-flex justify-content-between align-items-end my-4">
<h3 class="text-muted mb-0">
<%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3>
<div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}>
<i class="bi bi-asterisk me-sm-1"></i>
<span class="d-none d-sm-inline">Today</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<i class="bi bi-chevron-left me-sm-1"></i>
<span class="d-none d-sm-inline">Prev</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span>
<%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
<i class="bi bi-chevron-right ms-sm-1"></i>
</button>
</div>
</div>
<%= for day <- Enum.to_list(@date_range) do %>
<%= if Date.day_of_week(day, @current_user.week_start_at) == 1 do %>
<div class="border-top mt-4 mb-4"></div>
@ -45,24 +53,21 @@
<% day_shifts = Enum.filter(@shifts, fn s -> s.date == day end) %>
<%= if !Enum.empty?(day_shifts) do %>
<%= for shift <- day_shifts do %>
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id="shift-<%= shift.id %>">
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id={"shift-#{shift.id}"}>
<div class="card-body">
<h5 class="card-title">
<%= icon_div @socket, "bi-tag", [class: "icon baseline text-muted me-1"] %>
<i class="bi bi-tag text-muted me-1"></i>
<%= shift.subject %>
</h5>
<table class="table table-borderless table-nonfluid table-sm">
<tbody>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<i class="bi bi-hourglass text-muted"></i>
<span class="visually-hidden">Hours:</span>
</th>
<td>
@ -79,7 +84,7 @@
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<i class="bi bi-geo text-muted"></i>
<span class="visually-hidden">Location:</span>
</th>
<td>
@ -92,7 +97,7 @@
</tr>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<i class="bi bi-justify-left text-muted"></i>
<span class="visually-hidden">Description:</span>
</th>
<td class="shift-description">
@ -106,51 +111,22 @@
</tbody>
</table>
<%#= if Roles.can?(@current_user, template, :edit) do %>
<%#= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
<%#= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
<%# Edit %>
<%# end %>
<%# end %>
<%#= if Roles.can?(@current_user, template, :delete) do %>
<%# <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id=" %>
<%#= shift.id %>
<%# "> %>
<%#= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
<%# Delete %>
<%# </button> %>
<%# end %>
<%= button to: "#", phx_click: "delete", phx_value_id: shift.id, data: [confirm: "Are you sure?"], class: "btn btn-outline-danger btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<% end %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={shift.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
</div>
</div>
<% end %>
<% else %>
<p class="text-muted"><em>Nothing scheduled</em></p>
<% end %>
<% end %>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>
</div>

View File

@ -1,19 +0,0 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>

View File

@ -1,5 +1,6 @@
defmodule Shift73kWeb.ShiftTemplateLive.FormComponent do
use Shift73kWeb, :live_component
import Shift73k, only: [get_app_time_zone: 0]
alias Shift73k.Shifts.Templates
alias Shift73k.Shifts.Templates.ShiftTemplate

View File

@ -0,0 +1,119 @@
<div>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" phx-target={@myself} id="shift_template-form">
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :subject)}>
<i class="bi bi-tag icon is-left text-muted fs-5"></i>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for={input_id(f, :time_start)}>
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start),
required: true
%>
</div>
<div class="col-6" phx-feedback-for={input_id(f, :time_end)}>
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end),
required: true
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_start)}>
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_end)}>
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :location)}>
<i class="bi bi-geo icon is-left text-muted fs-5"></i>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for={input_id(f, :description)}>
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :time_zone)}>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list",
placeholder: "Default: #{get_app_time_zone()}"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value={tz_name}></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</.form>
</div>

View File

@ -1,116 +0,0 @@
<%= f = form_for @changeset, "#",
id: "shift_template-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :subject) %>">
<%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start)
%>
</div>
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end)
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :location) %>">
<%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :description) %>">
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :time_zone) %>">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</form>

View File

@ -1,17 +1,29 @@
<%= if @live_action in [:new, :edit, :clone] do %>
<%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.FormComponent,
id: @shift_template.id || :new,
title: @page_title,
action: @live_action,
shift_template: @shift_template,
current_user: @current_user %>
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftTemplateLive.FormComponent}
opts={[
id: @shift_template.id || :new,
title: @page_title,
action: @live_action,
shift_template: @shift_template,
current_user: @current_user
]}
/>
<% end %>
<%= if @delete_shift_template do %>
<%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftTemplateLive.DeleteComponent}
opts={[
id: @delete_shift_template.id,
title: "Delete Shift Template",
delete_shift_template: @delete_shift_template %>
delete_shift_template: @delete_shift_template
]}
/>
<% end %>
@ -20,12 +32,10 @@
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center">
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-clock-history", [class: "icon baseline"] %>
My Shift Templates
<i class="bi bi-clock-history me-1"></i> My Shift Templates
</h2>
<%= live_patch to: Routes.shift_template_index_path(@socket, :new), class: "btn btn-primary" do %>
<%= icon_div @socket, "bi-plus-circle-dotted", [class: "icon baseline"] %>
New Shift Template
<i class="bi bi-plus-circle-dotted me-1"></i> New Shift Template
<% end %>
</div>
@ -39,13 +49,13 @@
<div class="card mt-4">
<h5 class="card-header d-flex justify-content-between align-items-center">
<span class="visually-hidden">Subject:</span>
<%= icon_div @socket, "bi-tag", [class: "icon baseline me-1"] %>
<i class="bi bi-tag me-1"></i>
<div class="w-100"><%= template.subject %></div>
<%= if template.id == @current_user.fave_shift_template_id do %>
<%= icon_div @socket, "bi-star-fill", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Unset as favorite", phx_click: "unset-user-fave-shift-template", class: "cursor-pointer"] %>
<% else %>
<%= icon_div @socket, "bi-star", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Set as favorite", phx_click: "set-user-fave-shift-template", phx_value_id: template.id, class: "cursor-pointer"] %>
<% end %>
<% fav_icon_data = if template.id == @current_user.fave_shift_template_id, do:
{"bi-star-fill", "Unset as favorite", "unset-user-fave-shift-template"}, else:
{"bi-star", "Set as favorite", "set-user-fave-shift-template"}
%>
<i class={"bi #{elem(fav_icon_data, 0)} text-primary align-self-start ms-2 cursor-pointer"} role="img" aria-hidden="false" aria-label={elem(fav_icon_data, 1)} phx-click={elem(fav_icon_data, 2)} phx-value-id={template.id}></i>
</h5>
<div class="card-body">
@ -53,7 +63,7 @@
<tbody>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<i class="bi bi-hourglass text-muted"></i>
<span class="visually-hidden">Hours:</span>
</th>
<td>
@ -70,7 +80,7 @@
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<i class="bi bi-geo text-muted"></i>
<span class="visually-hidden">Location:</span>
</th>
<td>
@ -83,7 +93,7 @@
</tr>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<i class="bi bi-justify-left text-muted"></i>
<span class="visually-hidden">Description:</span>
</th>
<td>
@ -99,24 +109,19 @@
<%= if Roles.can?(@current_user, template, :edit) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, template, :clone) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :clone, template), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-clipboard-plus", [class: "icon baseline"] %>
Clone
<i class="bi bi-clipboard-plus me-1"></i> Clone
<% end %>
<% end %>
<%#= button "" %>
<%= if Roles.can?(@current_user, template, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= template.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={template.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>

View File

@ -1,6 +1,6 @@
defmodule Shift73kWeb.UserLive.Registration do
use Shift73kWeb, :live_view
alias Shift73k.Repo
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@ -20,9 +20,7 @@ defmodule Shift73kWeb.UserLive.Registration do
user_id: nil,
user_return_to: Map.get(session, "user_return_to", "/"),
messages: [
success: "Welcome! Your new account has been created, and you've been logged in.",
info:
"Some features may be unavailable until you confirm your email address. Check your inbox for instructions."
success: "Welcome! Your new account has been created, and you've been logged in."
]
}
end
@ -35,19 +33,33 @@ defmodule Shift73kWeb.UserLive.Registration do
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
is_first_user = !Repo.exists?(User)
user_params
|> Map.put("role", Accounts.registration_role())
|> Accounts.register_user()
|> case do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
# If this is the first user, we just confirm them
if is_first_user do
user |> User.confirm_changeset() |> Repo.update()
else
# Otherwise, all new users require email confirmation so we wend instructions
{:ok, _, %Swoosh.Email{} = _captured_email} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
end
login_params =
if is_first_user do
socket.assigns.login_params
else
put_in(socket.assigns.login_params, [:messages, :info], "Some features may be unavailable until you confirm your email address. Check your inbox for instructions.")
end
|> put_in([:user_id], user.id)
socket
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|> assign(login_params: login_params)
|> assign(trigger_submit: true)
|> live_noreply()

View File

@ -1,15 +1,16 @@
<div class="row justify-content-center">
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2><%= icon_div @socket, "bi-person-plus", [class: "icon baseline"] %>
Register</h2>
<h2>
<i class="bi bi-journal-plus me-1"></i> Register
</h2>
<p class="lead">Create an account to manage your work shifts with us.</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "reg_form"], fn f -> %>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="reg_form">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
@ -23,8 +24,8 @@
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -44,7 +45,7 @@
%>
</div>
<% end %>
</.form>
<p>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> |

View File

@ -1,9 +1,12 @@
defmodule Shift73kWeb.UserLive.ResetPassword do
use Shift73kWeb, :live_view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@impl true
def mount(_params, session, socket) do
user = Accounts.get_user!(session["user_id"])
@ -37,4 +40,6 @@ defmodule Shift73kWeb.UserLive.ResetPassword do
|> assign(changeset: changeset)}
end
end
def allow_registration, do: get_app_allow_reg()
end

View File

@ -2,16 +2,15 @@
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2>
<%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %>
Reset password
<i class="bi bi-shield-lock me-1"></i> Reset password
</h2>
<p class="lead">Hi <%= @user.email %> &mdash; tell us your new password, please.</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %>
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="pw_reset_form">
<%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -23,8 +22,8 @@
</div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}>
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
@ -43,10 +42,12 @@
%>
</div>
<% end %>
</.form>
<p class="mt-3 is-pulled-right">
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %>
</p>

View File

@ -0,0 +1,16 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3">
<i class="bi bi-sliders me-1"></i> User Settings
</h2>
<div class="row justify-content-center justify-content-md-start">
<.live_component module={Shift73kWeb.UserLive.Settings.Email} id={"email-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.Password} id={"password-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.WeekStart} id={"week_start-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.CalendarUrl} id={"calendar_url-#{@current_user.id}"} current_user={@current_user} />
</div>
</div>
</div>

View File

@ -1,15 +0,0 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3"><%= icon_div @socket, "bi-sliders", [class: "icon baseline"] %>
User Settings</h2>
<div class="row justify-content-center justify-content-md-start">
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Email, id: "email-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Password, id: "password-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.WeekStart, id: "week_start-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.CalendarUrl, id: "calendar_url-#{@current_user.id}", current_user: @current_user %>
</div>
</div>
</div>

View File

@ -1,11 +1,11 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1">
<h3>iCal Subscribe URL</h3>
<div class="row">
<div class="col mb-3">
<label class="form-label">Use this URL to subscribe in calendar software</label>
<input type="text" class="form-control" value="<%= Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug) %>" readonly onclick="this.focus();this.select()" />
<input type="text" class="form-control" value={Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug)} readonly onclick="this.focus();this.select()" />
</div>
</div>

View File

@ -1,12 +1,12 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change email</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
@ -20,7 +20,7 @@
<%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
id: "user_email_current_password",

View File

@ -1,12 +1,12 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change password</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -18,8 +18,8 @@
</div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}>
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
@ -31,7 +31,7 @@
<%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
id: "user_password_current_password",

View File

@ -1,4 +1,4 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Calendar view</h3>
@ -6,7 +6,7 @@
<%= label cvf, :week_start_at, "Week starts at", class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-calendar2-range", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-calendar2-range icon is-left text-muted fs-5"></i>
<%= select cvf, :week_start_at, week_start_options(),
value: @current_user.week_start_at,
class: "form-select"

View File

@ -9,9 +9,13 @@ defmodule Shift73kWeb.UserManagement.DeleteComponent do
end
@impl true
def handle_event("confirm", %{"id" => id, "email" => email}, socket) do
id
|> Accounts.get_user()
def handle_event("confirm", %{"id" => id, "email" => email} = params, socket) do
IO.inspect(params)
user = Accounts.get_user(id)
IO.inspect(user)
user
|> Accounts.delete_user()
|> case do
{:ok, _} ->

View File

@ -0,0 +1,20 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>
</div>

View File

@ -1,16 +0,0 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>

View File

@ -33,7 +33,7 @@ defmodule Shift73kWeb.UserManagement.FormComponent do
defp save_user(%{assigns: %{action: :new}} = socket, user_params) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
{:ok, _, %Swoosh.Email{} = _captured_email} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)

View File

@ -0,0 +1,66 @@
<div>
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<i class="bi bi-shield icon is-left text-muted fs-5"></i>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
</div>

View File

@ -1,62 +0,0 @@
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-shield", [class: "icon is-left text-muted fs-5"] %>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View File

@ -198,7 +198,7 @@ defmodule Shift73kWeb.UserManagementLive.Index do
def dt_out(ndt) do
ndt
|> DateTime.from_naive!(Shift73k.app_time_zone())
|> DateTime.from_naive!(Shift73k.get_app_time_zone())
|> Calendar.strftime("%Y %b %-d, %-I:%M %p")
end
end

View File

@ -1,24 +1,34 @@
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.FormComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.FormComponent}
opts={[
id: @user.id || :new,
title: @page_title,
action: @live_action,
user: @user,
current_user: @current_user %>
current_user: @current_user
]}
/>
<% end %>
<%= if @delete_user do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.DeleteComponent}
opts={[
id: @delete_user.id,
title: "Delete User",
delete_user: @delete_user
%>
]}
/>
<% end %>
<h2 class="mb-3">
<%= icon_div @socket, "bi-people", [class: "icon baseline"] %>
Listing Users
<i class="bi bi-people me-1"></i> Listing Users
</h2>
<%# filtering and new item creation %>
@ -27,8 +37,7 @@
<div class="col-12 col-md-6 col-lg-4 col-xl-3">
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
class: "btn btn-primary mb-3 mb-md-0" do %>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline me-1"] %>
New User
<i class="bi bi-person-plus me-1"></i> New User
<% end %>
</div>
@ -36,10 +45,10 @@
<%= form_for :sort, "#", [phx_change: "sort-by-change"], fn srt -> %>
<%= label srt, :sort_by, class: "visually-hidden" %>
<div class="input-group inner-addon left-addon mb-3 mb-md-0">
<%= icon_div @socket, "bi-arrow-down-up", [class: "icon is-left text-muted fs-5", style: "z-index:1001;"], [style: "padding: 1px;"] %>
<%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select" %>
<i class="bi bi-arrow-down-up icon is-left text-muted fs-5" style="z-index:1001;"></i>
<%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select rounded-start" %>
<button class="btn btn-primary" type="button" aria-label="Change sort order" phx-click="sort-order-change">
<%= icon_div @socket, (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"), [class: "icon baseline"] %>
<i class={if @query.sort_order == "desc", do: "bi bi-sort-up-alt", else: "bi bi-sort-down-alt"}></i>
</button>
</div>
<% end %>
@ -49,9 +58,9 @@
<%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %>
<%= label flt, :filter, class: "visually-hidden" %>
<div class="inner-addon left-addon right-addon">
<%= icon_div @socket, "bi-funnel", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-funnel icon is-left text-muted fs-5"></i>
<%= if @query.filter != "" do %>
<%= icon_div @socket, "bi-x-circle-fill", [class: "icon is-right text-primary fs-5"], [role: "img", aria_hidden: false, aria_label: "Clear filter", class: "cursor-pointer pe-auto", phx_click: "filter-clear"] %>
<i class="bi bi-x-circle-fill icon is-right text-primary fs-5 cursor-pointer pe-auto" role="img" aria-hidden="false" aria-label="Clear filter" phx-click="filter-clear"></i>
<% end %>
<%= text_input flt, :filter,
name: "filter",
@ -92,21 +101,19 @@
</dt>
<dd class="d-inline d-sm-block col-auto">
<span class="visually-hidden"><%= user.confirmed_at && "Yes" || "No" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled>
</dd>
</dl>
<%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>
@ -126,28 +133,25 @@
<thead>
<tr>
<th scope="col" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
Email
<%= if @query.sort_by == "email", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "email" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
Role
<%= if @query.sort_by == "role", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "role" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
Created at
<%= if @query.sort_by == "inserted_at", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "inserted_at" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col">Confirmed?</th>
@ -168,27 +172,25 @@
</tr>
<% else %>
<%= for user <- @page.entries do %>
<tr id="user-<%= user.id %>">
<tr id={"user-#{user.id}"}>
<td class="align-middle"><%= user.email %></td>
<td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td>
<td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td>
<td class="align-middle">
<span class="visually-hidden"><%= user.confirmed_at && "Confirmed" || "Not confirmed" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled>
</td>
<td class="align-middle text-end text-nowrap">
<%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>
@ -227,47 +229,47 @@
<ul class="pagination mb-0">
<%# previous page button %>
<% icon = icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<%= if @page.page_number == 1 do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-left"></i></span>
<span class="visually-hidden">Previous</span>
</li>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number="<%= @page.page_number - 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number={@page.page_number - 1} phx-click="page-change"><i class="bi bi-chevron-left"></i></a>
</li>
<% end %>
<%# page buttons %>
<%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %>
<%= cond do %>
<%= page_num < 1 -> %>
<% page_num < 1 -> %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true">&hellip;</span>
<span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span>
</li>
<% page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></a>
<span class="page-link"><%= page_num %></span>
</li>
<% true -> %>
<li class="page-item">
<a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
<a class="page-link" href="#" phx-value-page_number={page_num} phx-click="page-change"><%= page_num %></a>
</li>
<% end %>
<% end %>
<%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-right"></i></span>
<span class="visually-hidden">Next</span>
</li>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
<a class="page-link" href="#" aria-label="Next" phx-value-page_number={@page.page_number + 1} phx-click="page-change"><i class="bi bi-chevron-right"></i></a>
</li>
<% end %>
</ul>
</nav>

View File

@ -0,0 +1,34 @@
defmodule Shift73kWeb.EnsureAllowRegistrationPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Repo
alias Shift73k.Accounts.User
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users, or registration is allowed
if !Repo.exists?(User) || get_app_allow_reg() do
# We will allow registration
conn
else
# Otherwise,
# if app is configured to not allow registration,
# and there is a user,
# then we redirect to root URL
conn
|> redirect(to: "/")
|> halt()
end
end
end

View File

@ -27,8 +27,7 @@ defmodule Shift73kWeb.EnsureRolePlug do
def call(conn, roles) do
user_token = get_session(conn, :user_token)
(user_token &&
Accounts.get_user_by_session_token(user_token))
(user_token && Accounts.get_user_by_session_token(user_token))
|> has_role?(roles)
|> maybe_halt(conn)
end

View File

@ -0,0 +1,30 @@
defmodule Shift73kWeb.EnsureUserExistPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
alias Shift73k.Repo
alias Shift73k.Accounts.User
alias Shift73kWeb.Router.Helpers, as: Routes
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users,
if !Repo.exists?(User) do
# We're just going to redirect to registration
conn
|> redirect(to: Routes.user_registration_path(conn, :new))
|> halt()
else
# Otherwise we proceed as normal
conn
end
end
end

View File

@ -2,98 +2,100 @@ defmodule Shift73kWeb.Router do
use Shift73kWeb, :router
import Shift73kWeb.UserAuth
alias Shift73kWeb.EnsureRolePlug
alias Shift73kWeb.EnsureUserExistPlug
alias Shift73kWeb.EnsureAllowRegistrationPlug
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, {Shift73kWeb.LayoutView, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(:fetch_current_user)
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {Shift73kWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug(:accepts, ["json"])
pipeline :ensure_role_user do
plug EnsureRolePlug, [:admin, :manager, :user]
end
pipeline :user do
plug(EnsureRolePlug, [:admin, :manager, :user])
pipeline :ensure_user_exist do
plug EnsureUserExistPlug
end
pipeline :manager do
plug(EnsureRolePlug, [:admin, :manager])
pipeline :ensure_allow_registration do
plug EnsureAllowRegistrationPlug
end
pipeline :admin do
plug(EnsureRolePlug, :admin)
pipeline :ensure_role_manager do
plug EnsureRolePlug, [:admin, :manager]
end
scope "/", Shift73kWeb do
pipe_through([:browser])
get("/", Redirector, to: "/assign")
pipeline :ensure_role_admin do
plug EnsureRolePlug, :admin
end
# Other scopes may use custom stacks.
# scope "/api", Shift73kWeb do
# pipe_through :api
# end
# Enables LiveDashboard only for development
# Enables the Swoosh mailbox preview in development.
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through :browser
scope "/" do
pipe_through(:browser)
live_dashboard("/dashboard", metrics: Shift73kWeb.Telemetry)
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
scope "/", Shift73kWeb do
pipe_through([:browser, :redirect_if_user_is_authenticated])
pipe_through([:browser, :ensure_user_exist])
get "/", Redirector, to: "/assign"
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_allow_registration]
get "/users/register", UserRegistrationController, :new
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_user_exist]
# session routes, irrelevant if user is authenticated
get("/users/register", UserRegistrationController, :new)
get("/users/log_in", UserSessionController, :new)
post("/users/log_in", UserSessionController, :create)
get("/users/reset_password", UserResetPasswordController, :new)
post("/users/reset_password", UserResetPasswordController, :create)
get("/users/reset_password/:token", UserResetPasswordController, :edit)
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
end
scope "/", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user])
pipe_through [:browser, :require_authenticated_user]
# user settings (change email, password, calendar week start, etc)
live("/users/settings", UserLive.Settings, :edit)
live "/users/settings", UserLive.Settings, :edit
# confirm email by token
get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", Shift73kWeb do
pipe_through([:browser])
pipe_through [:browser, :ensure_user_exist]
# session paths
delete("/users/log_out", UserSessionController, :delete)
get("/users/force_logout", UserSessionController, :force_logout)
get("/users/confirm", UserConfirmationController, :new)
post("/users/confirm", UserConfirmationController, :create)
get("/users/confirm/:token", UserConfirmationController, :confirm)
delete "/users/log_out", UserSessionController, :delete
get "/users/force_logout", UserSessionController, :force_logout
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
# ics/ical route for user's shifts
get("/ics/:slug", UserShiftsIcsController, :index)
get "/ics/:slug", UserShiftsIcsController, :index
end
scope "/", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user, :user])
pipe_through [:browser, :require_authenticated_user, :ensure_role_user]
live "/templates", ShiftTemplateLive.Index, :index
live "/templates/new", ShiftTemplateLive.Index, :new
@ -111,16 +113,16 @@ defmodule Shift73kWeb.Router do
end
# scope "/", Shift73kWeb do
# pipe_through([:browser, :require_authenticated_user, :admin])
# pipe_through([:browser, :require_authenticated_user, :ensure_role_admin])
# end
# Users Management
scope "/users", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user, :manager, :require_email_confirmed])
pipe_through [:browser, :require_authenticated_user, :ensure_role_manager, :require_email_confirmed]
live("/", UserManagementLive.Index, :index)
live("/new", UserManagementLive.Index, :new)
live("/edit/:id", UserManagementLive.Index, :edit)
live "/", UserManagementLive.Index, :index
live "/new", UserManagementLive.Index, :new
live "/edit/:id", UserManagementLive.Index, :edit
# resources "/", UserManagementController, only: [:new, :create, :edit, :update]
end
end

View File

@ -1,88 +0,0 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<%= icon_div @conn, "bi-calendar2-week", [class: "icon baseline me-1"] %>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%= if @current_user do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-block d-lg-none") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<%#= if @current_user do %>
<%# <li class="nav-item"> %>
<%#= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "nav-link") do %>
<%#= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
<%# Templates %>
<%# end %>
<%# </li> %>
<%# end %>
<%# normal navbar link example %>
<%# <li class="nav-item"> %>
<%#= link "Properties", nav_link_opts(@conn, to: Routes.property_index_path(@conn, :index), class: "nav-link") %>
<%# </li> %>
<%# ACTIVE page link example %>
<%# <li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li> %>
<%# DISABLED page link example %>
<%# <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li> %>
<%# normal dropdown menu example %>
<%# <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownExample" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownExample">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li> %>
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-none d-lg-block") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,70 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<i class="bi bi-calendar2-week me-1"></i>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%# If there's a current user,
OR if there are users & we allow registration,
THEN we will show a full menu configuration %>
<%= if @current_user || (Repo.exists?(User) && allow_registration()) do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= render "navbar/_nouser_menu.html", assigns %>
<% end %>
</ul>
</div>
<%# If there's no current user,
AND:
There are no users -- [REGISTER]
OR no registration allowed -- [LOG IN] %>
<% else %>
<%= if !Repo.exists?(User) || allow_registration() do %>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-journal-plus"></i> Register
<% end %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-door-open"></i> Log in
<% end %>
<% end %>
<% end %>
</div>
</nav>

View File

@ -0,0 +1,10 @@
<%= if dev_env?() do %>
<script type="module" src="http://localhost:3000/@vite/client"></script>
<script type="module" src="http://localhost:3000/js/app.js"></script>
<% else %>
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-300-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-400-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-700-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
<% end %>

View File

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = get_flash(@conn, kind) do %>
<div class="alert <%= class %> alert-dismissible fade show" role="alert">
<div class={"alert #{class} alert-dismissible fade show"} role="alert">
<%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>

View File

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = live_flash(@flash, kind) do %>
<div class="alert <%= class %> alert-dismissible fade show" role="alert" id="lv-alert-<%= kind %>" phx-hook="AlertRemover" data-key="<%= kind %>">
<div class={"alert #{class} alert-dismissible fade show"} role="alert" id={"lv-alert-#{kind}"} phx-hook="AlertRemover" data-key={"#{kind}"}>
<%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>

View File

@ -0,0 +1,23 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownNoUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i> Hello?
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownNoUserMenu">
<li>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-journal-plus me-1"></i> Register
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-door-open me-1"></i> Log in
<% end %>
</li>
</ul>
</li>

View File

@ -1,28 +1,24 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<%= icon_div @conn, "bi-calendar2", [class: "icon baseline me-1"] %>
Shifts
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownShiftsMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-calendar2 me-1"></i> Shifts
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownShiftsMenu">
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_assign_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-calendar2-plus", [class: "icon baseline me-1"] %>
Schedule Shifts
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-card-list", [class: "icon baseline me-1"] %>
My Scheduled Shifts
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
My Shift Templates
<i class="bi bi-clock-history me-1"></i> My Shift Templates
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_index_path(@conn, :index), class: "dropdown-item") do %>
<i class="bi bi-card-list me-1"></i> My Scheduled Shifts
<% end %>
</li>
@ -30,14 +26,12 @@
<%# user_shifts_csv_path %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_shifts_csv_path(@conn, :new), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline me-1"] %>
CSV Export
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_import_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-box-arrow-in-left", [class: "icon baseline me-1"] %>
iCal Import
<i class="bi bi-box-arrow-in-left me-1"></i> iCal Import
<% end %>
</li>

View File

@ -1,8 +1,7 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<%= icon_div @conn, "bi-person-circle", [class: "icon baseline me-1"] %>
<%= @current_user && "Hello!" || "Hello?" %>
<i class="bi bi-person-circle me-1"></i> Hello!
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
@ -12,22 +11,19 @@
<%= if Roles.can?(@current_user, %User{}, :index) do %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_management_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-people", [class: "icon baseline me-1"] %>
Users
<i class="bi bi-people me-1"></i> Users
<% end %>
</li>
<li><hr class="dropdown-divider"></li>
<% end %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-sliders", [class: "icon baseline me-1"] %>
Settings
<i class="bi bi-sliders me-1"></i> Settings
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :delete), method: :delete, class: "dropdown-item") do %>
<%= icon_div @conn, "bi-door-closed", [class: "icon baseline me-1"] %>
Log out
<i class="bi bi-door-closed me-1"></i> Log out
<% end %>
</li>

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<Phoenix.Component.live_title suffix=" · Shift73k">
<%= assigns[:page_title] || "Hi!" %>
</Phoenix.Component.live_title>
<%= render "_preamble.html", assigns %>
<link rel="apple-touch-icon" sizes="180x180" href={Routes.static_path(@conn, "/apple-touch-icon.png")}>
<link rel="icon" type="image/png" sizes="32x32" href={Routes.static_path(@conn, "/favicon-32x32.png")}>
<link rel="icon" type="image/png" sizes="16x16" href={Routes.static_path(@conn, "/favicon-16x16.png")}>
<link rel="manifest" href={Routes.static_path(@conn, "/site.webmanifest")}>
<link rel="mask-icon" href={Routes.static_path(@conn, "/safari-pinned-tab.svg")} color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "", suffix: assigns[:page_title] && " · Shift73k" || "Shift73k" %>
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-300-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-400-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-700-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="apple-touch-icon" sizes="180x180" href="<%= Routes.static_path(@conn, "/apple-touch-icon.png") %>">
<link rel="icon" type="image/png" sizes="32x32" href="<%= Routes.static_path(@conn, "/favicon-32x32.png") %>">
<link rel="icon" type="image/png" sizes="16x16" href="<%= Routes.static_path(@conn, "/favicon-16x16.png") %>">
<link rel="manifest" href="<%= Routes.static_path(@conn, "/site.webmanifest") %>">
<link rel="mask-icon" href="<%= Routes.static_path(@conn, "/safari-pinned-tab.svg") %>" color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View File

@ -2,8 +2,7 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2>
<%= icon_div @conn, "bi-arrow-repeat", [class: "icon baseline"] %>
Resend confirmation instructions
<i class="bi bi-arrow-repeat me-1"></i> Resend confirmation instructions
</h2>
<p class="lead">We'll send you another email with instructions to confirm your email address.</p>
@ -11,7 +10,7 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: @current_user && @current_user.email || "",
placeholder: "e.g., babka@73k.us",
@ -30,6 +29,9 @@
<% end %>
<p>
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

View File

@ -2,8 +2,7 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2>
<%= icon_div @conn, "mdi-head-question-outline", [class: "icon baseline"] %>
Forgot your password?
<i class="bi bi-patch-question me-1"></i> Forgot your password?
</h2>
<p class="lead">We'll send you an email with instructions to reset your password.</p>
@ -11,7 +10,7 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
placeholder: "e.g., babka@73k.us",
class: "form-control",
@ -28,7 +27,9 @@
<% end %>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

View File

@ -1,13 +1,14 @@
<div class="row justify-content-center">
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2><%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in</h2>
<h2>
<i class="bi bi-door-open me-1"></i> Log in
</h2>
<p class="lead">Who goes there?</p>
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "needs-validation", novalidate: true], fn f -> %>
<%= if @error_message do %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<div class="alert alert-danger alert-dismissible fade show mt-4" role="alert">
<%= @error_message %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@ -15,11 +16,12 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
class: "form-control",
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
required: true
%>
<span class="invalid-feedback">must be a valid email address</span>
@ -27,7 +29,7 @@
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
class: "form-control",
required: true
@ -47,7 +49,9 @@
<% end %>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

View File

@ -2,8 +2,7 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline"] %>
CSV Export
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export
</h2>
<p class="lead">Select a date range for which to export a CSV of your scheduled shifts, or click "Export All" to export everything.</p>
@ -27,6 +26,7 @@
value: Date.beginning_of_month(today),
min: min_date,
max: max_date,
required: true,
class: "form-control"
%>
</div>
@ -37,6 +37,7 @@
value: Date.end_of_month(today),
min: min_date,
max: max_date,
required: true,
class: "form-control"
%>
</div>

View File

@ -2,8 +2,7 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @conn, "bi-calendar2", [class: "icon baseline"] %>
User Shifts ICS
<i class="bi bi-calendar2 me-1"></i> User Shifts ICS
</h2>
<p class="lead">Shifts for user: <%= @user.email %></p>
<p>Calendar slug: <%= @slug %></p>

View File

@ -1,38 +0,0 @@
defmodule Shift73kWeb.IconHelpers do
@moduledoc """
Generate SVG sprite use tags for SVG icons
"""
use Phoenix.HTML
alias Shift73kWeb.Router.Helpers, as: Routes
def icon_div(conn, name, div_opts \\ [], svg_opts \\ []) do
content_tag(:div, tag_opts(name, div_opts)) do
icon_svg(conn, name, svg_opts)
end
end
def icon_svg(conn, name, opts \\ []) do
opts = aria_hidden?(opts)
content_tag(:svg, tag_opts(name, opts)) do
~E"""
<%= if title = Keyword.get(opts, :aria_label), do: content_tag(:title, title) %>
<%= tag(:use, "xlink:href": Routes.static_path(conn, "/images/icons.svg##{name}")) %>
"""
end
end
defp tag_opts(name, opts) do
Keyword.update(opts, :class, name, fn c -> "#{c} #{name}" end)
end
defp aria_hidden?(opts) do
case Keyword.get(opts, :aria_hidden) do
"false" -> Keyword.drop(opts, [:aria_hidden])
false -> Keyword.drop(opts, [:aria_hidden])
"true" -> opts
_ -> Keyword.put(opts, :aria_hidden, "true")
end
end
end

View File

@ -1,9 +1,20 @@
defmodule Shift73kWeb.LayoutView do
use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Repo
alias Shift73k.Accounts.User
alias Shift73kWeb.Roles
# With a Vite.js-based workflow, we will import different asset files in development
# and in production builds. Therefore, we will need a way to conditionally render
# <script> tags based on Mix environment. However, since Mix is not available in
# releases, we need to cache the Mix environment at compile time. To this end:
@env Mix.env() # remember value at compile time
def dev_env?, do: @env == :dev
def allow_registration, do: get_app_allow_reg()
def nav_link_opts(conn, opts) do
case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do
false -> opts

View File

@ -1,4 +1,7 @@
defmodule Shift73kWeb.UserConfirmationView do
use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end

View File

@ -1,4 +1,7 @@
defmodule Shift73kWeb.UserResetPasswordView do
use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end

View File

@ -1,4 +1,7 @@
defmodule Shift73kWeb.UserSessionView do
use Shift73kWeb, :view
import Shift73k, only: [get_app_allow_reg: 0]
alias Shift73k.Accounts.User
def allow_registration, do: get_app_allow_reg()
end

42
mix.exs
View File

@ -4,10 +4,10 @@ defmodule Shift73k.MixProject do
def project do
[
app: :shift73k,
version: "0.1.0",
elixir: "~> 1.7",
version: "0.2.1",
elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
compilers: Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
@ -33,33 +33,30 @@ defmodule Shift73k.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bcrypt_elixir, "~> 2.0"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.4"},
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.6.11"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_live_view, "~> 0.15.0"},
{:floki, ">= 0.27.0", only: :test},
{:phoenix_html, "~> 2.11"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:bamboo, "~> 2.0"},
{:bamboo_smtp, "~> 4.0"},
{:phoenix_live_view, "~> 0.18"},
{:floki, ">= 0.30.0", only: :test},
{:swoosh, "~> 1.7"},
{:gen_smtp, "~> 1.2"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
{:scrivener_ecto, "~> 2.0"},
{:tzdata, "~> 1.1"},
{:nimble_csv, "~> 1.0"},
{:icalendar, "~> 1.1"},
{:httpoison, "~> 1.7"},
{:httpoison, "~> 2.0"},
# Additional packages
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.8", only: :dev}
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}
]
end
@ -74,7 +71,8 @@ defmodule Shift73k.MixProject do
setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.deploy": ["cmd npm --prefix assets run build", "phx.digest"]
]
end
end

View File

@ -1,60 +1,56 @@
%{
"bamboo": {:hex, :bamboo, "2.0.2", "0e2914d2bea0de3b1743384c24ffbe20fbb58094376a49f1cf5d9ed9959abd82", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "058d57cf4fcdac19413aa72732eb43c88954fb151a1cb6a382014e0cddbf6314"},
"bamboo_smtp": {:hex, :bamboo_smtp, "4.0.0", "0cc7df161d5d440d280a6d2eb20bf80bc45ea77161728a229e5ab339dcd087cd", [:mix], [{:bamboo, "~> 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.1.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "2412015092121b9f24f3f2e654bcd98e5c5f9afb323a94f8defa22e70ba8f23d"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.0", "6cb662d5c1b0a8858801cf20997bd006e7016aa8c52959c9ef80e0f34fb60b7a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2c81d61d4f6ed0e5cf7bf27a9109b791ff216a1034b3d541327484f46dd43769"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "1.0.0", "c25cd0794c054ebe6908a86820c8b92b5695814479ec95eeff35192720b71eec", [:mix], [], "hexpm", "577d0e855983a97ca1dfa33cbb8a3b6ece6767397ffb4861514343b078fc284b"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
"credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"},
"db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
"db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"ecto": {:hex, :ecto, "3.6.0", "df6b00f7278b458108044da4cff365dde31f6f2f621cf7dc0bf857b26be3bd20", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3035603f5b308ea7731b854493e5b5c1565e4d1e073186c3963b9689304f1d08"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
"ecto_sql": {:hex, :ecto_sql, "3.6.0", "5cb277b086618a644f2c5450316202a885716bb7726b9f13b74cb0708bea3a8f", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3486d6e29ee4a0e7a381390c9c289bfbbaf5dc1971e269c579799d2300e5bd5"},
"elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
"ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"},
"ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"},
"elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.30.1", "75d35526d3a1459920b6e87fdbc2e0b8a3670f965dd0903708d2b267e0904c55", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e9c03524447d1c4cbfccd672d739b8c18453eee377846b119d4fd71b1a176bb8"},
"gen_smtp": {:hex, :gen_smtp, "1.1.0", "b0c92138f69e2f73e1eb791075e93e952efcbc231a536740749b02a1a57155a3", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e53a13c4bb662331bdf4aa47f00982ef49ce2b4e5c703240542cb7b28f23546a"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
"gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"},
"gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"icalendar": {:hex, :icalendar, "1.1.0", "898a8640abb32d161d990e419999004718a7a4b48be31f48db248f90ca33fa6e", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "a131f45fbabd2ee5a22e6bc49ea91e81131158394e7169274cee866263640dca"},
"httpoison": {:hex, :httpoison, "2.0.0", "d38b091f5e481e45cc700aba8121ce49b66d348122a097c9fbc2dc6876d88090", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "f1253bf455be73a4c3f6ae3407e7e3cf6fc91934093e056d737a0566126e2930"},
"icalendar": {:hex, :icalendar, "1.1.2", "5d0afff5d0143c5bd43f18ae32a777bf0fb9a724543ab05229a460d368f0a5e7", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2060f8e353fdf3047e95a3f012583dc3c0bbd7ca1010e32ed9e9fc5760ad4292"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_csv": {:hex, :nimble_csv, "1.1.0", "b1dba4a86be9e03065c9de829050468e591f569100332db949e7ce71be0afc25", [:mix], [], "hexpm", "e986755bc302832cac429be6deda0fc9d82d3c82b47abefb68b3c17c9d949a3f"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
"phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"phx_gen_auth": {:hex, :phx_gen_auth, "0.6.0", "4ffbfa5b34ad8178c3dfcb996fed776df425903595cbc8d56a9ae5bc53136810", [:mix], [{:phoenix, "~> 1.5.2", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9a801c0f0bc251d8d91d62cecba0ebb6a90b8580fa8843029d931d15164e6ad9"},
"plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm", "30da36a427f2519cf75993271fb7c5aad1759682a70f90d880a85c3d743d2c57"},
"phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.3.2", "608819a638d527d0fd6e8892db431edd3f93c0452499880f94a80bbbcf8377e6", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "2c77c2eef273de37283bdc09c6506d466928f897600ea258fbb4765bbffd6ab2"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"},
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"timex": {:hex, :timex, "3.7.3", "df8a2ea814749d700d6878ab9eacac9fdb498ecee2f507cb0002ec172bc24d0f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8691c1d86ca3a7bc14a156e2199dc8927be95d1a8f0e3b69e4bb2d6262c53ac6"},
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}

View File

@ -5,28 +5,28 @@ defmodule Shift73k.Repo.Migrations.CreateUsersAuthTables do
execute("CREATE EXTENSION IF NOT EXISTS citext", "")
create table(:users, primary_key: false) do
add(:id, :binary_id, primary_key: true)
add(:email, :citext, null: false)
add(:hashed_password, :string, null: false)
add(:role, :string, null: false)
add(:confirmed_at, :naive_datetime)
add(:week_start_at, :string, null: false)
add(:calendar_slug, :string, null: false)
add :id, :binary_id, primary_key: true
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :role, :string, null: false
add :confirmed_at, :naive_datetime
add :week_start_at, :string, null: false
add :calendar_slug, :string, null: false
timestamps()
end
create(unique_index(:users, [:email, :calendar_slug]))
create unique_index(:users, [:email, :calendar_slug])
create table(:users_tokens, primary_key: false) do
add(:id, :binary_id, primary_key: true)
add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false)
add(:token, :binary, null: false)
add(:context, :string, null: false)
add(:sent_to, :string)
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
create(index(:users_tokens, [:user_id]))
create(unique_index(:users_tokens, [:context, :token]))
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View File

@ -3,7 +3,7 @@ defmodule Shift73k.Repo.Migrations.AddUserDefaultShiftColumn do
def change do
alter table(:users) do
add(:fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all))
add :fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all)
end
end
end

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