Compare commits
10 Commits
4e6dcd0c7b
...
e253076285
| Author | SHA1 | Date | |
|---|---|---|---|
| e253076285 | |||
| 35b9fab724 | |||
| 8336350ed4 | |||
| 7090e7d3a9 | |||
| eae49eeb78 | |||
| 9238a9158c | |||
| 392536fd7f | |||
| 584dc08292 | |||
| 709282091c | |||
| 9ebc9ab342 |
45
.dockerignore
Normal file
45
.dockerignore
Normal 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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -35,3 +35,5 @@ send_it-*.tar
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
setup_env.sh
|
||||
customers_master_list.csv
|
||||
|
||||
97
Dockerfile
Normal file
97
Dockerfile
Normal file
@ -0,0 +1,97 @@
|
||||
# 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-20241111-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.17.3-erlang-27.1.2-debian-bullseye-20241111-slim
|
||||
#
|
||||
ARG ELIXIR_VERSION=1.17.3
|
||||
ARG OTP_VERSION=27.1.2
|
||||
ARG DEBIAN_VERSION=bullseye-20241111-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 \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# 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
|
||||
|
||||
# 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 ca-certificates \
|
||||
&& 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/send_it ./
|
||||
|
||||
USER nobody
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
CMD ["/app/bin/server"]
|
||||
349
assets/css/app.css
Normal file
349
assets/css/app.css
Normal file
@ -0,0 +1,349 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400;1,700&family=IBM+Plex+Sans:ital,wght@0,200;0,400;0,700;1,200;1,400;1,700&family=IBM+Plex+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import url("./normalize.css");
|
||||
|
||||
:root {
|
||||
/* --blue: #0064ff;*/
|
||||
--grey: #d8d8d8;
|
||||
--grey-dark: #1f1d1c;
|
||||
--red: #ce2b2b;
|
||||
--blue: #3ab0ed;
|
||||
--magenta: #9b62fd;
|
||||
--white: #ffffff;
|
||||
--black: #000000;
|
||||
|
||||
--cyan: #1ee3cf;
|
||||
--yellow: #eed811;
|
||||
--pink: #eb0871;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(44, 43, 54);
|
||||
background: radial-gradient(circle, rgba(44, 43, 54, 1) 25%, rgba(41, 41, 44, 1) 100%);
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--magenta);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.802rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.602rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.424rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.266rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.889rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table-default {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin: 1rem 0;
|
||||
|
||||
& thead {
|
||||
border-bottom: 3px solid var(--magenta);
|
||||
}
|
||||
|
||||
& th,
|
||||
td {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
& td.is-link:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
& tr:nth-child(2n) {
|
||||
background-color: var(--grey-dark);
|
||||
}
|
||||
|
||||
& tbody tr:hover {
|
||||
color: white;
|
||||
background-color: var(--magenta);
|
||||
|
||||
& a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
& .table-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width:600px) {
|
||||
.table-default {
|
||||
& tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr
|
||||
}
|
||||
|
||||
& tfoot th {
|
||||
text-align: unset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Forms */
|
||||
label {
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type=datetime-local] {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
line-height: 1.75;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.25em;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: 3px solid #414141;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
caret-color: var(--magenta);
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
line-height: 1.75;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.25em;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 3px solid #414141;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
input[type=datetime-local]:focus,
|
||||
textarea:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--magenta);
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
background-color: var(--magenta);
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-default {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-direction: column;
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="radio"],
|
||||
input[type="checkbox"] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--magenta);
|
||||
}
|
||||
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-default {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
.site-nav {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
|
||||
.nav-list {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-wrapper,
|
||||
.modal-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal {
|
||||
box-sizing: border-box;
|
||||
overflow-y: scroll;
|
||||
|
||||
background: var(--grey-dark);
|
||||
margin: auto;
|
||||
max-height: 100%;
|
||||
padding: 1rem;
|
||||
width: calc(72ch + 48px);
|
||||
border: 0.5rem solid var(--magenta);
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.dl-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
& dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& dd {
|
||||
padding: 1rem;
|
||||
background-color: var(--grey-dark);
|
||||
margin: 0;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.banner {
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
background-color: var(--grey-dark);
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
& h2 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
& p {
|
||||
font-size: 1.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.text-content {
|
||||
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
227
assets/css/normalize.css
vendored
Normal file
227
assets/css/normalize.css
vendored
Normal file
@ -0,0 +1,227 @@
|
||||
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
|
||||
/*
|
||||
Document
|
||||
========
|
||||
*/
|
||||
|
||||
/**
|
||||
Use a better box model (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
/* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */
|
||||
font-family:
|
||||
system-ui,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji';
|
||||
line-height: 1.15;
|
||||
/* 1. Correct the line height in all browsers. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2. Prevent adjustments of font size after orientation changes in iOS. */
|
||||
tab-size: 4;
|
||||
/* 3. Use a more readable tab size (opinionated). */
|
||||
}
|
||||
|
||||
/*
|
||||
Sections
|
||||
========
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* Remove the margin in all browsers. */
|
||||
}
|
||||
|
||||
/*
|
||||
Text-level semantics
|
||||
====================
|
||||
*/
|
||||
|
||||
/**
|
||||
Add the correct font weight in Chrome and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
'Liberation Mono',
|
||||
Menlo,
|
||||
monospace;
|
||||
/* 1 */
|
||||
font-size: 1em;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
Tabular data
|
||||
============
|
||||
*/
|
||||
|
||||
/**
|
||||
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
*/
|
||||
|
||||
table {
|
||||
border-color: currentcolor;
|
||||
}
|
||||
|
||||
/*
|
||||
Forms
|
||||
=====
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
/* 1 */
|
||||
font-size: 100%;
|
||||
/* 1 */
|
||||
line-height: 1.15;
|
||||
/* 1 */
|
||||
margin: 0;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield;
|
||||
/* 1 */
|
||||
outline-offset: -2px;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
font: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Interactive
|
||||
===========
|
||||
*/
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
@ -36,7 +36,7 @@ config :esbuild,
|
||||
version: "0.17.11",
|
||||
send_it: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
~w(js/app.js css/app.css --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
||||
@ -102,16 +102,15 @@ if config_env() == :prod do
|
||||
# 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 :send_it, SendIt.Mailer,
|
||||
# adapter: Swoosh.Adapters.Mailgun,
|
||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||
config :send_it, SendIt.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
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SendIt.Finch
|
||||
#
|
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||
end
|
||||
|
||||
35
fly.toml
Normal file
35
fly.toml
Normal file
@ -0,0 +1,35 @@
|
||||
# fly.toml app configuration file generated for send-it on 2024-11-19T10:21:25-07:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = 'send-it'
|
||||
primary_region = 'den'
|
||||
kill_signal = 'SIGTERM'
|
||||
|
||||
[build]
|
||||
|
||||
[deploy]
|
||||
release_command = '/app/bin/migrate'
|
||||
|
||||
[env]
|
||||
PHX_HOST = 'send-it.fly.dev'
|
||||
PORT = '8080'
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = ['app']
|
||||
|
||||
[http_service.concurrency]
|
||||
type = 'connections'
|
||||
hard_limit = 1000
|
||||
soft_limit = 1000
|
||||
|
||||
[[vm]]
|
||||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
cpus = 1
|
||||
239
lib/send_it/marketing.ex
Normal file
239
lib/send_it/marketing.ex
Normal file
@ -0,0 +1,239 @@
|
||||
defmodule SendIt.Marketing do
|
||||
@moduledoc """
|
||||
The Marketing context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias SendIt.Repo
|
||||
|
||||
alias SendIt.Marketing.{Contact, Message, MessageNotifier}
|
||||
|
||||
@doc """
|
||||
Returns the list of contacts.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_contacts()
|
||||
[%Contact{}, ...]
|
||||
|
||||
"""
|
||||
def list_contacts(opts \\ [offset: 0, limit: 20]) do
|
||||
Repo.all(
|
||||
from c in Contact,
|
||||
offset: ^opts[:offset],
|
||||
limit: ^opts[:limit]
|
||||
)
|
||||
end
|
||||
|
||||
def list_subscribed_contacts do
|
||||
Repo.all(from c in Contact, where: [subscribed: true])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single contact.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Contact does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_contact!(123)
|
||||
%Contact{}
|
||||
|
||||
iex> get_contact!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_contact!(id), do: Repo.get!(Contact, id)
|
||||
|
||||
def get_contact_by_email!(email), do: Repo.get_by!(Contact, email: email)
|
||||
|
||||
@doc """
|
||||
Creates a contact.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_contact(%{field: value})
|
||||
{:ok, %Contact{}}
|
||||
|
||||
iex> create_contact(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_contact(attrs \\ %{}) do
|
||||
%Contact{}
|
||||
|> Contact.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a contact.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_contact(contact, %{field: new_value})
|
||||
{:ok, %Contact{}}
|
||||
|
||||
iex> update_contact(contact, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_contact(%Contact{} = contact, attrs) do
|
||||
contact
|
||||
|> Contact.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def unsubscribe(email) do
|
||||
case get_contact_by_email!(email) do
|
||||
%Contact{} = contact ->
|
||||
contact
|
||||
|> Contact.changeset(%{subscribed: false})
|
||||
|> Repo.update()
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a contact.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_contact(contact)
|
||||
{:ok, %Contact{}}
|
||||
|
||||
iex> delete_contact(contact)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_contact(%Contact{} = contact) do
|
||||
Repo.delete(contact)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking contact changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_contact(contact)
|
||||
%Ecto.Changeset{data: %Contact{}}
|
||||
|
||||
"""
|
||||
def change_contact(%Contact{} = contact, attrs \\ %{}) do
|
||||
Contact.changeset(contact, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of messages.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_messages()
|
||||
[%Message{}, ...]
|
||||
|
||||
"""
|
||||
def list_messages do
|
||||
Repo.all(from m in Message, order_by: [desc: m.inserted_at])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single message.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Message does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_message!(123)
|
||||
%Message{}
|
||||
|
||||
iex> get_message!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_message!(id), do: Repo.get!(Message, id)
|
||||
|
||||
@doc """
|
||||
Creates a message.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_message(%{field: value})
|
||||
{:ok, %Message{}}
|
||||
|
||||
iex> create_message(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_message(attrs \\ %{}) do
|
||||
%Message{}
|
||||
|> change_message(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the given message to the message's contacts list
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_message(message)
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_message(%Message{} = message) do
|
||||
message
|
||||
|> Repo.preload(:contacts)
|
||||
|> MessageNotifier.deliver_message()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a message.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_message(message, %{field: new_value})
|
||||
{:ok, %Message{}}
|
||||
|
||||
iex> update_message(message, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_message(%Message{} = message, attrs) do
|
||||
message
|
||||
|> change_message(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a message.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_message(message)
|
||||
{:ok, %Message{}}
|
||||
|
||||
iex> delete_message(message)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_message(%Message{} = message) do
|
||||
Repo.delete(message)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking message changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_message(message)
|
||||
%Ecto.Changeset{data: %Message{}}
|
||||
|
||||
"""
|
||||
def change_message(%Message{} = message, attrs \\ %{}) do
|
||||
contacts = list_subscribed_contacts()
|
||||
|
||||
message
|
||||
|> Message.changeset(attrs)
|
||||
|> Ecto.Changeset.put_assoc(:contacts, contacts)
|
||||
end
|
||||
end
|
||||
22
lib/send_it/marketing/contact.ex
Normal file
22
lib/send_it/marketing/contact.ex
Normal file
@ -0,0 +1,22 @@
|
||||
defmodule SendIt.Marketing.Contact do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "contacts" do
|
||||
field :name, :string
|
||||
field :email, :string
|
||||
field :subscribed, :boolean, default: true
|
||||
|
||||
many_to_many :messages, SendIt.Marketing.Message, join_through: "contacts_messages"
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(contact, attrs) do
|
||||
contact
|
||||
|> cast(attrs, [:name, :email, :subscribed])
|
||||
|> validate_required([:name, :email, :subscribed])
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
end
|
||||
20
lib/send_it/marketing/message.ex
Normal file
20
lib/send_it/marketing/message.ex
Normal file
@ -0,0 +1,20 @@
|
||||
defmodule SendIt.Marketing.Message do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "messages" do
|
||||
field :subject, :string
|
||||
field :content, :string
|
||||
|
||||
many_to_many :contacts, SendIt.Marketing.Contact, join_through: "contacts_messages"
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(message, attrs) do
|
||||
message
|
||||
|> cast(attrs, [:subject, :content])
|
||||
|> validate_required([:subject, :content])
|
||||
end
|
||||
end
|
||||
74
lib/send_it/marketing/message_notifier.ex
Normal file
74
lib/send_it/marketing/message_notifier.ex
Normal file
@ -0,0 +1,74 @@
|
||||
defmodule SendIt.Marketing.MessageNotifier do
|
||||
use SendItWeb, :html
|
||||
|
||||
require Logger
|
||||
import Swoosh.Email
|
||||
|
||||
alias SendIt.Mailer
|
||||
|
||||
@from_name "Port Townsend Roasting Co."
|
||||
@from_email "newsletter@ptcoffee.com"
|
||||
@chunk_by 900
|
||||
|
||||
def deliver_message(message) do
|
||||
contacts = message.contacts |> format_recipients() |> Enum.chunk_every(@chunk_by)
|
||||
body = message.content |> format_body()
|
||||
|
||||
Enum.map(contacts, &process_batch(&1, message.subject, body))
|
||||
end
|
||||
|
||||
defp process_batch(batch, subject, body) do
|
||||
Task.async(fn -> deliver(batch, subject, body) end) |> Task.await()
|
||||
end
|
||||
|
||||
defp deliver(recipients, subject, body) do
|
||||
email =
|
||||
new(
|
||||
to: recipients,
|
||||
from: {@from_name, @from_email},
|
||||
subject: subject,
|
||||
html_body: body
|
||||
)
|
||||
|> put_provider_option(:recipient_vars, format_recipient_vars(recipients))
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
{:ok, email}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Sending email failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_recipients(recipients) do
|
||||
Enum.map(recipients, &{&1.name, &1.email})
|
||||
end
|
||||
|
||||
defp format_recipient_vars(recipients) do
|
||||
Enum.reduce(recipients, %{}, fn {name, email}, acc ->
|
||||
Map.put_new(acc, email, %{name: name, email: email})
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_body(content) do
|
||||
content
|
||||
|> add_unsubscription()
|
||||
end
|
||||
|
||||
defp add_unsubscription(content) do
|
||||
url = url(~p"/subscription")
|
||||
|
||||
"""
|
||||
#{content}
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<hr />
|
||||
<p>
|
||||
<strong>Unsubscribe</strong><br />
|
||||
If you no longer wish to receive our emails, you can <a href="#{url}?email=%recipient.email%">unsubscribe here</a>.
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
28
lib/send_it/release.ex
Normal file
28
lib/send_it/release.ex
Normal file
@ -0,0 +1,28 @@
|
||||
defmodule SendIt.Release do
|
||||
@moduledoc """
|
||||
Used for executing DB release tasks when run in production without Mix
|
||||
installed.
|
||||
"""
|
||||
@app :send_it
|
||||
|
||||
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
|
||||
@ -47,42 +47,32 @@ defmodule SendItWeb.CoreComponents do
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden"
|
||||
class="modal-wrapper"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
class="modal"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label="close"
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="modal-container"
|
||||
>
|
||||
<div>
|
||||
<button phx-click={JS.exec("data-cancel", to: "##{@id}")} type="button" aria-label="close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
@ -114,20 +104,20 @@ defmodule SendItWeb.CoreComponents do
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
"fixed",
|
||||
@kind == :info && "",
|
||||
@kind == :error && ""
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
|
||||
<p :if={@title}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" />
|
||||
<%= @title %>
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-5"><%= msg %></p>
|
||||
<button type="button" class="group absolute top-1 right-1 p-2" aria-label="close">
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
|
||||
<p><%= msg %></p>
|
||||
<button type="button" aria-label="close">
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
@ -156,8 +146,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
phx-connected={hide("#client-error")}
|
||||
hidden
|
||||
>
|
||||
Attempting to reconnect
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
Attempting to reconnect <.icon name="hero-arrow-path" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
@ -168,8 +157,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
Hang in there while we get back on track
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
Hang in there while we get back on track <.icon name="hero-arrow-path" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
@ -200,12 +188,10 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def simple_form(assigns) do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="mt-10 space-y-8 bg-white">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
<.form :let={f} for={@for} as={@as} {@rest} class="form-default">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions}>
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
@ -217,7 +203,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" class="ml-2">Send!</.button>
|
||||
<.button phx-click="go">Send!</.button>
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
@ -230,8 +216,8 @@ defmodule SendItWeb.CoreComponents do
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
|
||||
"text-sm font-semibold leading-6 text-white active:text-white/80",
|
||||
"phx-submit-loading:opacity-75",
|
||||
"",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
@ -308,18 +294,10 @@ defmodule SendItWeb.CoreComponents do
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<div class="form-field">
|
||||
<label class="checkbox">
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
|
||||
{@rest}
|
||||
/>
|
||||
<input type="checkbox" id={@id} name={@name} value="true" checked={@checked} {@rest} />
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
@ -329,15 +307,9 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="form-field">
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<select id={@id} name={@name} multiple={@multiple} {@rest}>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
@ -348,15 +320,15 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="form-field">
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
"",
|
||||
@errors == [] && "",
|
||||
@errors != [] && ""
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
@ -368,7 +340,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="form-field">
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<input
|
||||
type={@type}
|
||||
@ -376,9 +348,9 @@ defmodule SendItWeb.CoreComponents do
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
"",
|
||||
@errors == [] && "",
|
||||
@errors != [] && ""
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
@ -395,7 +367,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
|
||||
<label for={@for}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</label>
|
||||
"""
|
||||
@ -408,8 +380,8 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<p class="error">
|
||||
<.icon name="hero-exclamation-circle-mini" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
@ -426,16 +398,16 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<header class={[@actions != [] && "", "header-default", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<h2>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
</h2>
|
||||
<p :if={@subtitle != []}>
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
<div><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
@ -464,6 +436,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to a table"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
@ -472,49 +445,38 @@ defmodule SendItWeb.CoreComponents do
|
||||
end
|
||||
|
||||
~H"""
|
||||
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
|
||||
<table class="w-[40rem] mt-11 sm:w-full">
|
||||
<thead class="text-sm text-left leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th :if={@action != []} class="relative p-0 pb-4">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id={@id}
|
||||
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
||||
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
|
||||
>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table class="table-default">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}><%= col[:label] %></th>
|
||||
<th :if={@action != []}>
|
||||
<span>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} {@rest}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["", @row_click && ""]}
|
||||
>
|
||||
<div>
|
||||
<span class={["", i == 0 && ""]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []}>
|
||||
<div class="table-actions">
|
||||
<span :for={action <- @action}>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -534,14 +496,12 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<dl class="dl-default">
|
||||
<div :for={item <- @item}>
|
||||
<dt><%= item.title %></dt>
|
||||
<dd><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -557,13 +517,9 @@ defmodule SendItWeb.CoreComponents do
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
<div class="back-link">
|
||||
<.link navigate={@navigate}>
|
||||
← <%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
@ -585,7 +541,7 @@ defmodule SendItWeb.CoreComponents do
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
<.icon name="hero-arrow-path" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
@ -669,4 +625,11 @@ defmodule SendItWeb.CoreComponents do
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts time into readable time
|
||||
"""
|
||||
def format_date(date) do
|
||||
Calendar.strftime(date, "%a, %b %d %Y")
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,32 +1,4 @@
|
||||
<header class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
</a>
|
||||
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
|
||||
v<%= Application.spec(:phoenix, :vsn) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
|
||||
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
|
||||
@elixirphoenix
|
||||
</a>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
|
||||
>
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<.flash_group flash={@flash} />
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
<main>
|
||||
<.flash_group flash={@flash} />
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="[scrollbar-gutter:stable]">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@ -7,52 +7,44 @@
|
||||
<.live_title suffix=" · Phoenix Framework">
|
||||
<%= assigns[:page_title] || "SendIt" %>
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
|
||||
<%= if @current_user do %>
|
||||
<li class="text-[0.8125rem] leading-6 text-zinc-900">
|
||||
<%= @current_user.email %>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/users/settings"}
|
||||
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/users/log_out"}
|
||||
method="delete"
|
||||
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||
>
|
||||
Log out
|
||||
</.link>
|
||||
</li>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/users/register"}
|
||||
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||
>
|
||||
Register
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/users/log_in"}
|
||||
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||
>
|
||||
Log in
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<body>
|
||||
<nav class="site-nav">
|
||||
<h1>Send It</h1>
|
||||
<ul class="nav-list">
|
||||
<%= if @current_user do %>
|
||||
<li>
|
||||
<.link href={~p"/messages"}>
|
||||
Messages
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/contacts"}>
|
||||
Contacts
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>
|
||||
Settings
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/log_out"} method="delete">
|
||||
Log out
|
||||
</.link>
|
||||
</li>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link href={~p"/users/log_in"}>
|
||||
Log in
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</nav>
|
||||
<%= @inner_content %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -2,8 +2,6 @@ defmodule SendItWeb.PageController do
|
||||
use SendItWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
# The home page is often custom made,
|
||||
# so skip the default app layout.
|
||||
render(conn, :home, layout: false)
|
||||
render(conn, :home)
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,223 +1,4 @@
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/home.css"} />
|
||||
<.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
<path
|
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||
fill="#FF9F92"
|
||||
/>
|
||||
<path
|
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||
fill="#FA8372"
|
||||
/>
|
||||
<path
|
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||
fill="#E96856"
|
||||
fill-opacity=".6"
|
||||
/>
|
||||
<path
|
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||
fill="#C42652"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
|
||||
v<%= Application.spec(:phoenix, :vsn) %>
|
||||
</small>
|
||||
</h1>
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 text-base leading-7 text-zinc-600">
|
||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
stroke="#18181B"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Guides & Docs
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||
fill="#18181B"
|
||||
/>
|
||||
</svg>
|
||||
Source Code
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="#18181B"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill="#18181B"
|
||||
fill-opacity=".15"
|
||||
stroke="#18181B"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Changelog
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://twitter.com/elixirphoenix"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
|
||||
</svg>
|
||||
Follow on Twitter
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://web.libera.chat/#elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
|
||||
/>
|
||||
</svg>
|
||||
Chat on Libera IRC
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner">
|
||||
<h2>Send It</h2>
|
||||
<p>Instantly compose and send a newsletter to your contacts.</p>
|
||||
</div>
|
||||
|
||||
84
lib/send_it_web/live/contact_live/form_component.ex
Normal file
84
lib/send_it_web/live/contact_live/form_component.ex
Normal file
@ -0,0 +1,84 @@
|
||||
defmodule SendItWeb.ContactLive.FormComponent do
|
||||
use SendItWeb, :live_component
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.header>
|
||||
<%= @title %>
|
||||
<:subtitle>Use this form to manage contact records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="contact-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.input field={@form[:name]} type="text" label="Name" />
|
||||
<.input field={@form[:email]} type="text" label="Email" />
|
||||
<.input field={@form[:subscribed]} type="checkbox" label="Subscribed" />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Saving...">Save Contact</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{contact: contact} = assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:form, fn ->
|
||||
to_form(Marketing.change_contact(contact))
|
||||
end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"contact" => contact_params}, socket) do
|
||||
changeset = Marketing.change_contact(socket.assigns.contact, contact_params)
|
||||
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"contact" => contact_params}, socket) do
|
||||
save_contact(socket, socket.assigns.action, contact_params)
|
||||
end
|
||||
|
||||
defp save_contact(socket, :edit, contact_params) do
|
||||
case Marketing.update_contact(socket.assigns.contact, contact_params) do
|
||||
{:ok, contact} ->
|
||||
notify_parent({:saved, contact})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contact updated successfully")
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_contact(socket, :new, contact_params) do
|
||||
case Marketing.create_contact(contact_params) do
|
||||
{:ok, contact} ->
|
||||
notify_parent({:saved, contact})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contact created successfully")
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
84
lib/send_it_web/live/contact_live/index.ex
Normal file
84
lib/send_it_web/live/contact_live/index.ex
Normal file
@ -0,0 +1,84 @@
|
||||
defmodule SendItWeb.ContactLive.Index do
|
||||
use SendItWeb, :live_view
|
||||
|
||||
alias SendIt.Marketing
|
||||
alias SendIt.Marketing.Contact
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(page: 1, per_page: 20)
|
||||
|> paginate_contacts(1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Contact")
|
||||
|> assign(:contact, %Contact{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Contacts")
|
||||
|> assign(:contact, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({SendItWeb.ContactLive.FormComponent, {:saved, contact}}, socket) do
|
||||
{:noreply, stream_insert(socket, :contacts, contact)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
contact = Marketing.get_contact!(id)
|
||||
{:ok, _} = Marketing.delete_contact(contact)
|
||||
|
||||
{:noreply, stream_delete(socket, :contacts, contact)}
|
||||
end
|
||||
|
||||
def handle_event("next-page", _, socket) do
|
||||
IO.puts("NEXT Pageee")
|
||||
{:noreply, paginate_contacts(socket, socket.assigns.page + 1)}
|
||||
end
|
||||
|
||||
def handle_event("prev-page", %{"_overran" => true}, socket) do
|
||||
{:noreply, paginate_contacts(socket, 1)}
|
||||
end
|
||||
|
||||
def handle_event("prev-page", _, socket) do
|
||||
if socket.assigns.page > 1 do
|
||||
{:noreply, paginate_contacts(socket, socket.assigns.page - 1)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp paginate_contacts(socket, new_page) when new_page >= 1 do
|
||||
%{per_page: per_page, page: cur_page} = socket.assigns
|
||||
contacts = Marketing.list_contacts(offset: (new_page - 1) * per_page, limit: per_page)
|
||||
|
||||
{contacts, at, limit} =
|
||||
if new_page >= cur_page do
|
||||
{contacts, -1, per_page * 3 * -1}
|
||||
else
|
||||
{Enum.reverse(contacts), 0, per_page * 3}
|
||||
end
|
||||
|
||||
case contacts do
|
||||
[] ->
|
||||
assign(socket, end_of_timeline?: at == -1)
|
||||
|
||||
[_ | _] = contacts ->
|
||||
socket
|
||||
|> assign(end_of_timeline?: false)
|
||||
|> assign(:page, new_page)
|
||||
|> stream(:contacts, contacts, at: at, limit: limit)
|
||||
end
|
||||
end
|
||||
end
|
||||
42
lib/send_it_web/live/contact_live/index.html.heex
Normal file
42
lib/send_it_web/live/contact_live/index.html.heex
Normal file
@ -0,0 +1,42 @@
|
||||
<.header>
|
||||
Contacts
|
||||
<:actions>
|
||||
<.link patch={~p"/contacts/new"}>
|
||||
<.button>New Contact</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="contacts"
|
||||
rows={@streams.contacts}
|
||||
row_click={fn {_id, contact} -> JS.navigate(~p"/contacts/#{contact}") end}
|
||||
phx-viewport-top={@page > 1 && "prev-page"}
|
||||
phx-viewport-bottom={!@end_of_timeline? && "next-page"}
|
||||
phx-page-loading
|
||||
>
|
||||
<:col :let={{_id, contact}} label="Name"><%= contact.name %></:col>
|
||||
<:col :let={{_id, contact}} label="Email"><%= contact.email %></:col>
|
||||
<:col :let={{_id, contact}} label="Subscribed"><%= contact.subscribed %></:col>
|
||||
<:action :let={{id, contact}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: contact.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<h3 :if={@end_of_timeline?}>No more contacts!</h3>
|
||||
|
||||
<.modal :if={@live_action in [:new]} id="contact-modal" show on_cancel={JS.patch(~p"/contacts")}>
|
||||
<.live_component
|
||||
module={SendItWeb.ContactLive.FormComponent}
|
||||
id={:new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
contact={@contact}
|
||||
patch={~p"/contacts"}
|
||||
/>
|
||||
</.modal>
|
||||
21
lib/send_it_web/live/contact_live/show.ex
Normal file
21
lib/send_it_web/live/contact_live/show.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule SendItWeb.ContactLive.Show do
|
||||
use SendItWeb, :live_view
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:contact, Marketing.get_contact!(id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Contact"
|
||||
defp page_title(:edit), do: "Edit Contact"
|
||||
end
|
||||
27
lib/send_it_web/live/contact_live/show.html.heex
Normal file
27
lib/send_it_web/live/contact_live/show.html.heex
Normal file
@ -0,0 +1,27 @@
|
||||
<.back navigate={~p"/contacts"}>Back to contacts</.back>
|
||||
|
||||
<.header>
|
||||
Contact
|
||||
<:actions>
|
||||
<.link patch={~p"/contacts/#{@contact}/edit"} phx-click={JS.push_focus()}>
|
||||
<.button>Edit contact</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Name"><%= @contact.name %></:item>
|
||||
<:item title="Email"><%= @contact.email %></:item>
|
||||
<:item title="Subscribed"><%= @contact.subscribed %></:item>
|
||||
</.list>
|
||||
|
||||
<.modal :if={@live_action == :edit} id="contact-modal" show on_cancel={JS.patch(~p"/contacts/#{@contact}")}>
|
||||
<.live_component
|
||||
module={SendItWeb.ContactLive.FormComponent}
|
||||
id={@contact.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
contact={@contact}
|
||||
patch={~p"/contacts/#{@contact}"}
|
||||
/>
|
||||
</.modal>
|
||||
72
lib/send_it_web/live/message_live/form_component.ex
Normal file
72
lib/send_it_web/live/message_live/form_component.ex
Normal file
@ -0,0 +1,72 @@
|
||||
defmodule SendItWeb.MessageLive.FormComponent do
|
||||
use SendItWeb, :live_component
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex-column">
|
||||
<.header>
|
||||
<%= @title %>
|
||||
<:subtitle>Messages will automatically be sent to all contacts.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="message-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.input field={@form[:subject]} type="text" label="Subject" />
|
||||
<.input field={@form[:content]} type="textarea" label="Content" />
|
||||
<:actions>
|
||||
<.button data-confirm="This will send to all contacts!" phx-disable-with="Sending...">
|
||||
Send Message
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{message: message} = assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:form, fn ->
|
||||
to_form(Marketing.change_message(message))
|
||||
end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"message" => message_params}, socket) do
|
||||
changeset = Marketing.change_message(socket.assigns.message, message_params)
|
||||
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"message" => message_params}, socket) do
|
||||
save_message(socket, socket.assigns.action, message_params)
|
||||
end
|
||||
|
||||
defp save_message(socket, :new, message_params) do
|
||||
case Marketing.create_message(message_params) do
|
||||
{:ok, message} ->
|
||||
[{:ok, _email} | _rest] = Marketing.deliver_message(message)
|
||||
|
||||
notify_parent({:saved, message})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Message created successfully")
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
47
lib/send_it_web/live/message_live/index.ex
Normal file
47
lib/send_it_web/live/message_live/index.ex
Normal file
@ -0,0 +1,47 @@
|
||||
defmodule SendItWeb.MessageLive.Index do
|
||||
use SendItWeb, :live_view
|
||||
|
||||
alias SendIt.Marketing
|
||||
alias SendIt.Marketing.Message
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, stream(socket, :messages, Marketing.list_messages())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Edit Message")
|
||||
|> assign(:message, Marketing.get_message!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Message")
|
||||
|> assign(:message, %Message{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Messages")
|
||||
|> assign(:message, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({SendItWeb.MessageLive.FormComponent, {:saved, message}}, socket) do
|
||||
{:noreply, stream_insert(socket, :messages, message, at: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
message = Marketing.get_message!(id)
|
||||
{:ok, _} = Marketing.delete_message(message)
|
||||
|
||||
{:noreply, stream_delete(socket, :messages, message)}
|
||||
end
|
||||
end
|
||||
45
lib/send_it_web/live/message_live/index.html.heex
Normal file
45
lib/send_it_web/live/message_live/index.html.heex
Normal file
@ -0,0 +1,45 @@
|
||||
<.header>
|
||||
Messages
|
||||
<:actions>
|
||||
<.link patch={~p"/messages/new"}>
|
||||
<.button>New Message</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="messages"
|
||||
rows={@streams.messages}
|
||||
row_click={fn {_id, message} -> JS.navigate(~p"/messages/#{message}") end}
|
||||
>
|
||||
<:col :let={{_id, message}} label="Subject">
|
||||
<%= message.subject %>
|
||||
</:col>
|
||||
<:col :let={{_id, message}} label="Created">
|
||||
<%= format_date(message.inserted_at) %>
|
||||
</:col>
|
||||
<:action :let={{id, message}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: message.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.modal
|
||||
:if={@live_action in [:new]}
|
||||
id="message-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/messages")}
|
||||
>
|
||||
<.live_component
|
||||
module={SendItWeb.MessageLive.FormComponent}
|
||||
id={@message.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
message={@message}
|
||||
patch={~p"/messages"}
|
||||
/>
|
||||
</.modal>
|
||||
21
lib/send_it_web/live/message_live/show.ex
Normal file
21
lib/send_it_web/live/message_live/show.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule SendItWeb.MessageLive.Show do
|
||||
use SendItWeb, :live_view
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:message, Marketing.get_message!(id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Message"
|
||||
defp page_title(:edit), do: "Edit Message"
|
||||
end
|
||||
33
lib/send_it_web/live/message_live/show.html.heex
Normal file
33
lib/send_it_web/live/message_live/show.html.heex
Normal file
@ -0,0 +1,33 @@
|
||||
<.back navigate={~p"/messages"}>Back to messages</.back>
|
||||
|
||||
<.header>
|
||||
Message
|
||||
<:subtitle>Sent: <%= format_date(@message.inserted_at) %></:subtitle>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Subject">
|
||||
<%= @message.subject %>
|
||||
</:item>
|
||||
<:item title="Content">
|
||||
<div class="text-content">
|
||||
<%= raw(@message.content) %>
|
||||
</div>
|
||||
</:item>
|
||||
</.list>
|
||||
|
||||
<.modal
|
||||
:if={@live_action == :edit}
|
||||
id="message-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/messages/#{@message}")}
|
||||
>
|
||||
<.live_component
|
||||
module={SendItWeb.MessageLive.FormComponent}
|
||||
id={@message.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
message={@message}
|
||||
patch={~p"/messages/#{@message}"}
|
||||
/>
|
||||
</.modal>
|
||||
56
lib/send_it_web/live/subscription_live.ex
Normal file
56
lib/send_it_web/live/subscription_live.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule SendItWeb.SubscriptionLive do
|
||||
use SendItWeb, :live_view
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, assign_form(socket, params)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.header>
|
||||
Unsubscribe
|
||||
<:subtitle>Enter your email address below to unsubscribe from all emails.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form for={@form} id="subscription-form" phx-submit="save">
|
||||
<.input type="email" field={@form[:email]} />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Saving changes...">Unsubscribe</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"email" => email}, socket) do
|
||||
unsubscribe(socket, email)
|
||||
end
|
||||
|
||||
defp unsubscribe(socket, email) do
|
||||
case Marketing.unsubscribe(email) do
|
||||
{:ok, _contact} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_form(%{})
|
||||
|> put_flash(:info, "You have been unsubscribed from all emails.")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, reason)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, params) do
|
||||
email = handle_params(params)
|
||||
assign(socket, :form, to_form(%{"email" => email}))
|
||||
end
|
||||
|
||||
defp handle_params(%{"email" => email}), do: email
|
||||
|
||||
defp handle_params(_params), do: ""
|
||||
end
|
||||
@ -21,8 +21,7 @@ defmodule SendItWeb.UserConfirmationInstructionsLive do
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
| <.link href={~p"/users/log_in"}>Log in</.link>
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@ -16,8 +16,7 @@ defmodule SendItWeb.UserConfirmationLive do
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
| <.link href={~p"/users/log_in"}>Log in</.link>
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@ -20,8 +20,7 @@ defmodule SendItWeb.UserForgotPasswordLive do
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<p class="text-center text-sm mt-4">
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
| <.link href={~p"/users/log_in"}>Log in</.link>
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@ -6,13 +6,6 @@ defmodule SendItWeb.UserLoginLive do
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Log in to account
|
||||
<:subtitle>
|
||||
Don't have an account?
|
||||
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||
Sign up
|
||||
</.link>
|
||||
for an account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
|
||||
|
||||
@ -31,8 +31,7 @@ defmodule SendItWeb.UserResetPasswordLive do
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center text-sm mt-4">
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
| <.link href={~p"/users/log_in"}>Log in</.link>
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@ -21,6 +21,7 @@ defmodule SendItWeb.Router do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
live "/subscription", SubscriptionLive
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
@ -52,7 +53,7 @@ defmodule SendItWeb.Router do
|
||||
|
||||
live_session :redirect_if_user_is_authenticated,
|
||||
on_mount: [{SendItWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||
live "/users/register", UserRegistrationLive, :new
|
||||
# live "/users/register", UserRegistrationLive, :new
|
||||
live "/users/log_in", UserLoginLive, :new
|
||||
live "/users/reset_password", UserForgotPasswordLive, :new
|
||||
live "/users/reset_password/:token", UserResetPasswordLive, :edit
|
||||
@ -66,6 +67,15 @@ defmodule SendItWeb.Router do
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{SendItWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/messages", MessageLive.Index, :index
|
||||
live "/messages/new", MessageLive.Index, :new
|
||||
live "/messages/:id", MessageLive.Show, :show
|
||||
|
||||
live "/contacts", ContactLive.Index, :index
|
||||
live "/contacts/new", ContactLive.Index, :new
|
||||
live "/contacts/:id", ContactLive.Show, :show
|
||||
live "/contacts/:id/edit", ContactLive.Show, :edit
|
||||
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
|
||||
end
|
||||
|
||||
5
mix.exs
5
mix.exs
@ -40,7 +40,7 @@ defmodule SendIt.MixProject do
|
||||
{:phoenix_html, "~> 4.1"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
# TODO bump on release to {:phoenix_live_view, "~> 1.0.0"},
|
||||
{:phoenix_live_view, "~> 1.0.0-rc.1", override: true},
|
||||
{:phoenix_live_view, "~> 1.0.0-rc.7", override: true},
|
||||
{:floki, ">= 0.30.0", only: :test},
|
||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
|
||||
@ -50,7 +50,8 @@ defmodule SendIt.MixProject do
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:dns_cluster, "~> 0.1.1"},
|
||||
{:bandit, "~> 1.5"}
|
||||
{:bandit, "~> 1.5"},
|
||||
{:multipart, "~> 0.4.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
4
mix.lock
4
mix.lock
@ -1,11 +1,14 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"},
|
||||
"castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"},
|
||||
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
|
||||
"decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
|
||||
"ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 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", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 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", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
|
||||
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
|
||||
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
|
||||
@ -15,6 +18,7 @@
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
|
||||
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
|
||||
"multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
|
||||
|
||||
15
priv/repo/migrations/20241114191936_create_contacts.exs
Normal file
15
priv/repo/migrations/20241114191936_create_contacts.exs
Normal file
@ -0,0 +1,15 @@
|
||||
defmodule SendIt.Repo.Migrations.CreateContacts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:contacts) do
|
||||
add :name, :string
|
||||
add :email, :string
|
||||
add :subscribed, :boolean, default: true, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:contacts, [:email])
|
||||
end
|
||||
end
|
||||
12
priv/repo/migrations/20241114192041_create_messages.exs
Normal file
12
priv/repo/migrations/20241114192041_create_messages.exs
Normal file
@ -0,0 +1,12 @@
|
||||
defmodule SendIt.Repo.Migrations.CreateMessages do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:messages) do
|
||||
add :subject, :string
|
||||
add :content, :text
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,12 @@
|
||||
defmodule SendIt.Repo.Migrations.CreateContactsMessages do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:contacts_messages, primary_key: false) do
|
||||
add :contact_id, references(:contacts, on_delete: :delete_all)
|
||||
add :message_id, references(:messages, on_delete: :delete_all)
|
||||
end
|
||||
|
||||
create unique_index(:contacts_messages, [:contact_id, :message_id])
|
||||
end
|
||||
end
|
||||
13
rel/env.sh.eex
Executable file
13
rel/env.sh.eex
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
# configure node for distributed erlang with IPV6 support
|
||||
export ERL_AFLAGS="-proto_dist inet6_tcp"
|
||||
export ECTO_IPV6="true"
|
||||
export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal"
|
||||
export RELEASE_DISTRIBUTION="name"
|
||||
export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"
|
||||
|
||||
# Uncomment to send crash dumps to stderr
|
||||
# This can be useful for debugging, but may log sensitive information
|
||||
# export ERL_CRASH_DUMP=/dev/stderr
|
||||
# export ERL_CRASH_DUMP_BYTES=4096
|
||||
5
rel/overlays/bin/migrate
Executable file
5
rel/overlays/bin/migrate
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
exec ./send_it eval SendIt.Release.migrate
|
||||
1
rel/overlays/bin/migrate.bat
Executable file
1
rel/overlays/bin/migrate.bat
Executable file
@ -0,0 +1 @@
|
||||
call "%~dp0\send_it" eval SendIt.Release.migrate
|
||||
5
rel/overlays/bin/server
Executable file
5
rel/overlays/bin/server
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
PHX_SERVER=true exec ./send_it start
|
||||
2
rel/overlays/bin/server.bat
Executable file
2
rel/overlays/bin/server.bat
Executable file
@ -0,0 +1,2 @@
|
||||
set PHX_SERVER=true
|
||||
call "%~dp0\send_it" start
|
||||
119
test/send_it/marketing_test.exs
Normal file
119
test/send_it/marketing_test.exs
Normal file
@ -0,0 +1,119 @@
|
||||
defmodule SendIt.MarketingTest do
|
||||
use SendIt.DataCase
|
||||
|
||||
alias SendIt.Marketing
|
||||
|
||||
describe "contacts" do
|
||||
alias SendIt.Marketing.Contact
|
||||
|
||||
import SendIt.MarketingFixtures
|
||||
|
||||
@invalid_attrs %{name: nil, email: nil, subscribed: nil}
|
||||
|
||||
test "list_contacts/0 returns all contacts" do
|
||||
contact = contact_fixture()
|
||||
assert Marketing.list_contacts() == [contact]
|
||||
end
|
||||
|
||||
test "get_contact!/1 returns the contact with given id" do
|
||||
contact = contact_fixture()
|
||||
assert Marketing.get_contact!(contact.id) == contact
|
||||
end
|
||||
|
||||
test "create_contact/1 with valid data creates a contact" do
|
||||
valid_attrs = %{name: "some name", email: "some email", subscribed: true}
|
||||
|
||||
assert {:ok, %Contact{} = contact} = Marketing.create_contact(valid_attrs)
|
||||
assert contact.name == "some name"
|
||||
assert contact.email == "some email"
|
||||
assert contact.subscribed == true
|
||||
end
|
||||
|
||||
test "create_contact/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Marketing.create_contact(@invalid_attrs)
|
||||
end
|
||||
|
||||
test "update_contact/2 with valid data updates the contact" do
|
||||
contact = contact_fixture()
|
||||
update_attrs = %{name: "some updated name", email: "some updated email", subscribed: false}
|
||||
|
||||
assert {:ok, %Contact{} = contact} = Marketing.update_contact(contact, update_attrs)
|
||||
assert contact.name == "some updated name"
|
||||
assert contact.email == "some updated email"
|
||||
assert contact.subscribed == false
|
||||
end
|
||||
|
||||
test "update_contact/2 with invalid data returns error changeset" do
|
||||
contact = contact_fixture()
|
||||
assert {:error, %Ecto.Changeset{}} = Marketing.update_contact(contact, @invalid_attrs)
|
||||
assert contact == Marketing.get_contact!(contact.id)
|
||||
end
|
||||
|
||||
test "delete_contact/1 deletes the contact" do
|
||||
contact = contact_fixture()
|
||||
assert {:ok, %Contact{}} = Marketing.delete_contact(contact)
|
||||
assert_raise Ecto.NoResultsError, fn -> Marketing.get_contact!(contact.id) end
|
||||
end
|
||||
|
||||
test "change_contact/1 returns a contact changeset" do
|
||||
contact = contact_fixture()
|
||||
assert %Ecto.Changeset{} = Marketing.change_contact(contact)
|
||||
end
|
||||
end
|
||||
|
||||
describe "messages" do
|
||||
alias SendIt.Marketing.Message
|
||||
|
||||
import SendIt.MarketingFixtures
|
||||
|
||||
@invalid_attrs %{subject: nil, content: nil}
|
||||
|
||||
test "list_messages/0 returns all messages" do
|
||||
message = message_fixture()
|
||||
assert Marketing.list_messages() == [message]
|
||||
end
|
||||
|
||||
test "get_message!/1 returns the message with given id" do
|
||||
message = message_fixture()
|
||||
assert Marketing.get_message!(message.id) == message
|
||||
end
|
||||
|
||||
test "create_message/1 with valid data creates a message" do
|
||||
valid_attrs = %{subject: "some subject", content: "some content"}
|
||||
|
||||
assert {:ok, %Message{} = message} = Marketing.create_message(valid_attrs)
|
||||
assert message.subject == "some subject"
|
||||
assert message.content == "some content"
|
||||
end
|
||||
|
||||
test "create_message/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Marketing.create_message(@invalid_attrs)
|
||||
end
|
||||
|
||||
test "update_message/2 with valid data updates the message" do
|
||||
message = message_fixture()
|
||||
update_attrs = %{subject: "some updated subject", content: "some updated content"}
|
||||
|
||||
assert {:ok, %Message{} = message} = Marketing.update_message(message, update_attrs)
|
||||
assert message.subject == "some updated subject"
|
||||
assert message.content == "some updated content"
|
||||
end
|
||||
|
||||
test "update_message/2 with invalid data returns error changeset" do
|
||||
message = message_fixture()
|
||||
assert {:error, %Ecto.Changeset{}} = Marketing.update_message(message, @invalid_attrs)
|
||||
assert message == Marketing.get_message!(message.id)
|
||||
end
|
||||
|
||||
test "delete_message/1 deletes the message" do
|
||||
message = message_fixture()
|
||||
assert {:ok, %Message{}} = Marketing.delete_message(message)
|
||||
assert_raise Ecto.NoResultsError, fn -> Marketing.get_message!(message.id) end
|
||||
end
|
||||
|
||||
test "change_message/1 returns a message changeset" do
|
||||
message = message_fixture()
|
||||
assert %Ecto.Changeset{} = Marketing.change_message(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
113
test/send_it_web/live/contact_live_test.exs
Normal file
113
test/send_it_web/live/contact_live_test.exs
Normal file
@ -0,0 +1,113 @@
|
||||
defmodule SendItWeb.ContactLiveTest do
|
||||
use SendItWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import SendIt.MarketingFixtures
|
||||
|
||||
@create_attrs %{name: "some name", email: "some email", subscribed: true}
|
||||
@update_attrs %{name: "some updated name", email: "some updated email", subscribed: false}
|
||||
@invalid_attrs %{name: nil, email: nil, subscribed: false}
|
||||
|
||||
defp create_contact(_) do
|
||||
contact = contact_fixture()
|
||||
%{contact: contact}
|
||||
end
|
||||
|
||||
describe "Index" do
|
||||
setup [:create_contact]
|
||||
|
||||
test "lists all contacts", %{conn: conn, contact: contact} do
|
||||
{:ok, _index_live, html} = live(conn, ~p"/contacts")
|
||||
|
||||
assert html =~ "Listing Contacts"
|
||||
assert html =~ contact.name
|
||||
end
|
||||
|
||||
test "saves new contact", %{conn: conn} do
|
||||
{:ok, index_live, _html} = live(conn, ~p"/contacts")
|
||||
|
||||
assert index_live |> element("a", "New Contact") |> render_click() =~
|
||||
"New Contact"
|
||||
|
||||
assert_patch(index_live, ~p"/contacts/new")
|
||||
|
||||
assert index_live
|
||||
|> form("#contact-form", contact: @invalid_attrs)
|
||||
|> render_change() =~ "can't be blank"
|
||||
|
||||
assert index_live
|
||||
|> form("#contact-form", contact: @create_attrs)
|
||||
|> render_submit()
|
||||
|
||||
assert_patch(index_live, ~p"/contacts")
|
||||
|
||||
html = render(index_live)
|
||||
assert html =~ "Contact created successfully"
|
||||
assert html =~ "some name"
|
||||
end
|
||||
|
||||
test "updates contact in listing", %{conn: conn, contact: contact} do
|
||||
{:ok, index_live, _html} = live(conn, ~p"/contacts")
|
||||
|
||||
assert index_live |> element("#contacts-#{contact.id} a", "Edit") |> render_click() =~
|
||||
"Edit Contact"
|
||||
|
||||
assert_patch(index_live, ~p"/contacts/#{contact}/edit")
|
||||
|
||||
assert index_live
|
||||
|> form("#contact-form", contact: @invalid_attrs)
|
||||
|> render_change() =~ "can't be blank"
|
||||
|
||||
assert index_live
|
||||
|> form("#contact-form", contact: @update_attrs)
|
||||
|> render_submit()
|
||||
|
||||
assert_patch(index_live, ~p"/contacts")
|
||||
|
||||
html = render(index_live)
|
||||
assert html =~ "Contact updated successfully"
|
||||
assert html =~ "some updated name"
|
||||
end
|
||||
|
||||
test "deletes contact in listing", %{conn: conn, contact: contact} do
|
||||
{:ok, index_live, _html} = live(conn, ~p"/contacts")
|
||||
|
||||
assert index_live |> element("#contacts-#{contact.id} a", "Delete") |> render_click()
|
||||
refute has_element?(index_live, "#contacts-#{contact.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show" do
|
||||
setup [:create_contact]
|
||||
|
||||
test "displays contact", %{conn: conn, contact: contact} do
|
||||
{:ok, _show_live, html} = live(conn, ~p"/contacts/#{contact}")
|
||||
|
||||
assert html =~ "Show Contact"
|
||||
assert html =~ contact.name
|
||||
end
|
||||
|
||||
test "updates contact within modal", %{conn: conn, contact: contact} do
|
||||
{:ok, show_live, _html} = live(conn, ~p"/contacts/#{contact}")
|
||||
|
||||
assert show_live |> element("a", "Edit") |> render_click() =~
|
||||
"Edit Contact"
|
||||
|
||||
assert_patch(show_live, ~p"/contacts/#{contact}/show/edit")
|
||||
|
||||
assert show_live
|
||||
|> form("#contact-form", contact: @invalid_attrs)
|
||||
|> render_change() =~ "can't be blank"
|
||||
|
||||
assert show_live
|
||||
|> form("#contact-form", contact: @update_attrs)
|
||||
|> render_submit()
|
||||
|
||||
assert_patch(show_live, ~p"/contacts/#{contact}")
|
||||
|
||||
html = render(show_live)
|
||||
assert html =~ "Contact updated successfully"
|
||||
assert html =~ "some updated name"
|
||||
end
|
||||
end
|
||||
end
|
||||
42
test/support/fixtures/marketing_fixtures.ex
Normal file
42
test/support/fixtures/marketing_fixtures.ex
Normal file
@ -0,0 +1,42 @@
|
||||
defmodule SendIt.MarketingFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `SendIt.Marketing` context.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Generate a unique contact email.
|
||||
"""
|
||||
def unique_contact_email, do: "some email#{System.unique_integer([:positive])}"
|
||||
|
||||
@doc """
|
||||
Generate a contact.
|
||||
"""
|
||||
def contact_fixture(attrs \\ %{}) do
|
||||
{:ok, contact} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
email: unique_contact_email(),
|
||||
name: "some name",
|
||||
subscribed: true
|
||||
})
|
||||
|> SendIt.Marketing.create_contact()
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate a message.
|
||||
"""
|
||||
def message_fixture(attrs \\ %{}) do
|
||||
{:ok, message} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
content: "some content",
|
||||
subject: "some subject"
|
||||
})
|
||||
|> SendIt.Marketing.create_message()
|
||||
|
||||
message
|
||||
end
|
||||
end
|
||||
Loading…
x
Reference in New Issue
Block a user