diff --git a/.formatter.exs b/.formatter.exs
index 197a6c9d..6bde019f 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,5 +1,7 @@
[
- inputs: ["*.exs", "{config,lib,priv,rel,test}/**/*.{ex,exs}"],
- line_length: 180,
- plugins: [Styler]
+ import_deps: [:ecto, :ecto_sql, :phoenix],
+ subdirectories: ["priv/*/migrations"],
+ plugins: [Phoenix.LiveView.HTMLFormatter],
+ inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
+ line_length: 180
]
diff --git a/.gitignore b/.gitignore
index 89a9ec10..cf5eda12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,10 @@
/db
/deps
/*.ez
-/cover
+
+# Temporary files
/tmp
+/cover
# Generated on crash by the VM
erl_crash.dump
diff --git a/Dockerfile b/Dockerfile
index fc2d8834..ef453ca1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,6 +38,9 @@ ENV MIX_ENV="prod"
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
+# Setup assets dependencies (Esbuild, Tailwind, etc…) so the are cached
+RUN mix assets.setup
+
# Copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
diff --git a/assets/css/app.css b/assets/css/app.css
index 989c539b..a31e4441 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,43 +1,3 @@
-.home {
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- padding: 40px;
- text-align: center;
- font-family:
- system-ui,
- -apple-system,
- 'Segoe UI',
- Roboto,
- 'Helvetica Neue',
- Arial,
- 'Noto Sans',
- 'Liberation Sans',
- sans-serif,
- 'Apple Color Emoji',
- 'Segoe UI Emoji',
- 'Segoe UI Symbol',
- 'Noto Color Emoji';
- line-height: 1.4;
-}
-
-.home a {
- display: block;
- margin: 0 0 20px;
-}
-
-.home p {
- margin: 0 0 20px;
-}
-
-.home p:last-child {
- margin-bottom: 0;
-}
-
-.flash-messages {
- position: fixed;
- top: 0;
- right: 0;
- padding: 10px;
-}
+@import 'tailwindcss/base';
+@import 'tailwindcss/components';
+@import 'tailwindcss/utilities';
diff --git a/assets/js/app.js b/assets/js/app.js
index 7a224a63..47bee88f 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,38 +1,22 @@
-import 'simple-css-reset/reset.css';
-import '../css/app.css';
+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
+import 'phoenix_html';
+// Establish Phoenix Socket and LiveView configuration.
import {Socket} from 'phoenix';
import {LiveSocket} from 'phoenix_live_view';
-const FLASH_TTL = 8000;
-const Hooks = {};
-
-Hooks.Flash = {
- mounted() {
- this.timer = setTimeout(() => this._hide(), FLASH_TTL);
-
- this.el.addEventListener('mouseover', () => {
- clearTimeout(this.timer);
- this.timer = setTimeout(() => this._hide(), FLASH_TTL);
- });
- },
-
- destroyed() {
- clearTimeout(this.timer);
- },
-
- _hide() {
- liveSocket.execJS(this.el, this.el.getAttribute('phx-click'));
- }
-};
-
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute('content');
-
const liveSocket = new LiveSocket('/live', Socket, {
- hooks: Hooks,
params: {_csrf_token: csrfToken} // eslint-disable-line camelcase
});
+// connect if there are any LiveViews on the page
liveSocket.connect();
+
+// expose liveSocket on window for web console debug logs and latency simulation:
+// >> liveSocket.enableDebug()
+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
+// >> liveSocket.disableLatencySim()
+window.liveSocket = liveSocket;
diff --git a/assets/package-lock.json b/assets/package-lock.json
index bf207c1d..6dadc0ef 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -7,12 +7,6 @@
"": {
"name": "elixir-boilerplate",
"version": "0.0.1",
- "dependencies": {
- "phoenix": "^1.7.7",
- "phoenix_html": "^3.3.1",
- "phoenix_live_view": "^0.20.5",
- "simple-css-reset": "^3.0.0"
- },
"devDependencies": {
"@babel/eslint-parser": "^7.23.10",
"eslint": "^8.56.0",
@@ -29,11 +23,13 @@
}
},
"../deps/phoenix": {
- "version": "1.7.7",
+ "version": "1.7.11",
+ "extraneous": true,
"license": "MIT"
},
"../deps/phoenix_html": {
- "version": "3.3.2"
+ "version": "3.3.3",
+ "extraneous": true
},
"node_modules/@aashutoshrathi/word-wrap": {
"version": "1.2.6",
@@ -2432,19 +2428,6 @@
"node": ">=8"
}
},
- "node_modules/phoenix": {
- "resolved": "../deps/phoenix",
- "link": true
- },
- "node_modules/phoenix_html": {
- "resolved": "../deps/phoenix_html",
- "link": true
- },
- "node_modules/phoenix_live_view": {
- "version": "0.20.5",
- "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.20.5.tgz",
- "integrity": "sha512-FgwuGVvanKLs8dZj7k2JBI2fBijcY5DJoFS7A1va4+PAF+R5lqLSwIhHQF1PTtcoV95Av9v9kmj/3yMgTiY9JQ=="
- },
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -2771,11 +2754,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/simple-css-reset": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/simple-css-reset/-/simple-css-reset-3.0.0.tgz",
- "integrity": "sha512-IN1NRbrCL9pLVBFzzyXmJfkgJAS4b5VwcWFXdpEGMx9asEUZ7AbSnqbLHnB5CvCPa7+uu41aLAsguiUQQvrBmw=="
- },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
diff --git a/assets/package.json b/assets/package.json
index c9ef1acc..a2e44ce1 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -7,12 +7,6 @@
"node": "^20.5.0",
"npm": "^9.8.0"
},
- "dependencies": {
- "phoenix": "^1.7.7",
- "phoenix_html": "^3.3.1",
- "phoenix_live_view": "^0.20.5",
- "simple-css-reset": "^3.0.0"
- },
"devDependencies": {
"@babel/eslint-parser": "^7.23.10",
"eslint": "^8.56.0",
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
new file mode 100644
index 00000000..148264b0
--- /dev/null
+++ b/assets/tailwind.config.js
@@ -0,0 +1,99 @@
+/* eslint-env node */
+
+// See the Tailwind configuration guide for advanced usage
+// https://tailwindcss.com/docs/configuration
+
+const plugin = require('tailwindcss/plugin');
+// const fs = require('fs');
+// const path = require('path');
+
+module.exports = {
+ content: ['./js/**/*.js', '../lib/*_web.ex', '../lib/*_web/**/*.*ex'],
+ theme: {
+ extend: {
+ colors: {
+ brand: '#FD4F00'
+ }
+ }
+ },
+ plugins: [
+ require('@tailwindcss/forms'),
+ // Allows prefixing tailwind classes with LiveView classes to add rules
+ // only when LiveView classes are applied, for example:
+ //
+ //
+ //
+ plugin(({addVariant}) =>
+ addVariant('phx-no-feedback', ['.phx-no-feedback&', '.phx-no-feedback &'])
+ ),
+ plugin(({addVariant}) =>
+ addVariant('phx-click-loading', [
+ '.phx-click-loading&',
+ '.phx-click-loading &'
+ ])
+ ),
+ plugin(({addVariant}) =>
+ addVariant('phx-submit-loading', [
+ '.phx-submit-loading&',
+ '.phx-submit-loading &'
+ ])
+ ),
+ plugin(({addVariant}) =>
+ addVariant('phx-change-loading', [
+ '.phx-change-loading&',
+ '.phx-change-loading &'
+ ])
+ ),
+ plugin(({addVariant}) =>
+ addVariant('phx-change-loading', [
+ '.phx-change-loading&',
+ '.phx-change-loading &'
+ ])
+ )
+
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ // plugin(({matchComponents, theme}) => {
+ // const iconsDir = path.join(__dirname, './vendor/heroicons/optimized');
+ // const values = {};
+ // const icons = [
+ // ['', '/24/outline'],
+ // ['-solid', '/24/solid'],
+ // ['-mini', '/20/solid']
+ // ];
+
+ // /* eslint max-nested-callbacks: ["error", 3] */
+ // icons.forEach(([suffix, dir]) => {
+ // fs.readdirSync(path.join(iconsDir, dir)).map((file) => {
+ // const name = path.basename(file, '.svg') + suffix;
+
+ // values[name] = {name, fullPath: path.join(iconsDir, dir, file)};
+ // });
+ // });
+
+ // matchComponents(
+ // {
+ // hero: ({name, fullPath}) => {
+ // const content = fs
+ // .readFileSync(fullPath)
+ // .toString()
+ // .replace(/\r?\n|\r/g, '');
+
+ // return {
+ // [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ // '-webkit-mask': `var(--hero-${name})`,
+ // mask: `var(--hero-${name})`,
+ // 'mask-repeat': 'no-repeat',
+ // 'background-color': 'currentColor',
+ // 'vertical-align': 'middle',
+ // display: 'inline-block',
+ // width: theme('spacing.5'),
+ // height: theme('spacing.5')
+ // };
+ // }
+ // },
+ // {values}
+ // );
+ // })
+ ]
+};
diff --git a/config/config.exs b/config/config.exs
index 8832a514..108d2233 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -10,7 +10,10 @@ config :phoenix, :json_library, Jason
config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint,
pubsub_server: ElixirBoilerplate.PubSub,
- render_errors: [view: ElixirBoilerplateWeb.Errors, accepts: ~w(html json)]
+ render_errors: [
+ formats: [html: ElixirBoilerplateWeb.Controllers.ErrorHTML, json: ElixirBoilerplateWeb.Controllers.ErrorJSON],
+ layout: false
+ ]
config :elixir_boilerplate, ElixirBoilerplate.Repo,
migration_primary_key: [type: :binary_id, default: {:fragment, "gen_random_uuid()"}],
@@ -30,13 +33,24 @@ config :absinthe_security, AbsintheSecurity.Phase.MaxDepthCheck, max_depth_count
config :absinthe_security, AbsintheSecurity.Phase.MaxDirectivesCheck, max_directive_count: 100
config :esbuild,
- version: "0.16.4",
+ version: "0.17.11",
default: [
- args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets),
+ args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
+config :tailwind,
+ version: "3.2.7",
+ default: [
+ args: ~w(
+ --config=tailwind.config.js
+ --input=css/app.css
+ --output=../priv/static/assets/app.css
+ ),
+ cd: Path.expand("../assets", __DIR__)
+ ]
+
config :sentry,
included_environments: [:all],
root_source_code_path: File.cwd!(),
diff --git a/config/dev.exs b/config/dev.exs
index 10c51388..07282ca2 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -5,13 +5,14 @@ config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint,
debug_errors: true,
check_origin: false,
watchers: [
- esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
+ esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
],
live_reload: [
patterns: [
- ~r{priv/gettext/.*$},
- ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
- ~r{lib/elixir_boilerplate_web/.*(ee?x)$}
+ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/gettext/.*(po)$",
+ ~r"lib/elixir_boilerplate_web/../.*(ex|heex)$"
]
]
@@ -21,3 +22,6 @@ config :logger, :console, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime
+
+# Enable dev routes for dashboard and mailbox
+config :elixir_boilerplate, dev_routes: true
diff --git a/config/prod.exs b/config/prod.exs
index 65651460..28f645cf 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -8,3 +8,6 @@ config :logger, :console,
format: "$time $metadata[$level] $message\n",
level: :info,
metadata: ~w(request_id graphql_operation_name)a
+
+# Runtime production configuration, including reading
+# of environment variables, is done on config/runtime.exs.
diff --git a/config/runtime.exs b/config/runtime.exs
index a79f2cef..555b2284 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -20,11 +20,14 @@ if get_env("PHX_SERVER", :boolean) == true do
config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, server: true
end
+config :elixir_boilerplate, ElixirBoilerplateWeb.Session,
+ session_key: get_env!("SESSION_KEY"),
+ session_signing_salt: get_env!("SESSION_SIGNING_SALT")
+
config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint,
http: [port: get_env!("PORT", :integer)],
secret_key_base: get_env!("SECRET_KEY_BASE"),
session_key: get_env!("SESSION_KEY"),
- session_signing_salt: get_env!("SESSION_SIGNING_SALT"),
live_view: [signing_salt: get_env!("SESSION_SIGNING_SALT")],
url: get_endpoint_url_config(canonical_uri),
static_url: get_endpoint_url_config(static_uri)
diff --git a/lib/elixir_boilerplate_web/api/version/controller.ex b/lib/elixir_boilerplate_web/api/version/controller.ex
new file mode 100644
index 00000000..b5d4e357
--- /dev/null
+++ b/lib/elixir_boilerplate_web/api/version/controller.ex
@@ -0,0 +1,8 @@
+defmodule ElixirBoilerplateWeb.Api.Version.Controller do
+ use ElixirBoilerplateWeb.Controller
+
+ @spec index(Plug.Conn.t(), map) :: Plug.Conn.t()
+ def index(conn, _) do
+ json(conn, %{version: Application.get_env(:elixir_boilerplate, :version)})
+ end
+end
diff --git a/lib/elixir_boilerplate_web/component.ex b/lib/elixir_boilerplate_web/component.ex
new file mode 100644
index 00000000..342c81c0
--- /dev/null
+++ b/lib/elixir_boilerplate_web/component.ex
@@ -0,0 +1,9 @@
+defmodule ElixirBoilerplateWeb.Component do
+ defmacro __using__(_opts) do
+ quote do
+ use Phoenix.LiveComponent
+
+ unquote(ElixirBoilerplateWeb.html_helpers())
+ end
+ end
+end
diff --git a/lib/elixir_boilerplate_web/components/branding.ex b/lib/elixir_boilerplate_web/components/branding.ex
new file mode 100644
index 00000000..220e196f
--- /dev/null
+++ b/lib/elixir_boilerplate_web/components/branding.ex
@@ -0,0 +1,22 @@
+defmodule ElixirBoilerplateWeb.Components.Branding do
+ @moduledoc """
+ Provides branding UI components.
+ """
+ use Phoenix.Component
+
+ @doc """
+ Renders the boilerplate logo as an inlined SVG.
+ ## Examples
+ <.logo width=500 />
+ """
+ attr :width, :integer, default: 500
+
+ def logo(assigns) do
+ ~H"""
+
+ """
+ end
+end
diff --git a/lib/elixir_boilerplate_web/components/core.ex b/lib/elixir_boilerplate_web/components/core.ex
new file mode 100644
index 00000000..56fd82b5
--- /dev/null
+++ b/lib/elixir_boilerplate_web/components/core.ex
@@ -0,0 +1,568 @@
+defmodule ElixirBoilerplateWeb.Components.Core do
+ @moduledoc """
+ Provides core UI components.
+ At the first glance, this module may seem daunting, but its goal is
+ to provide some core building blocks in your application, such modals,
+ tables, and forms. The components are mostly markup and well documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+ The default components use Tailwind CSS, a utility-first CSS framework.
+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+ how to customize them or feel free to swap in another framework altogether.
+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+ """
+ use Phoenix.Component
+
+ alias Phoenix.LiveView.JS
+ import ElixirBoilerplate.Gettext
+
+ @doc """
+ Renders a modal.
+ ## Examples
+ <.modal id="confirm-modal">
+ This is a modal.
+
+ JS commands may be passed to the `:on_cancel` to configure
+ the closing/cancel event, for example:
+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+ This is another modal.
+
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :on_cancel, JS, default: %JS{}
+ slot :inner_block, required: true
+
+ def modal(assigns) do
+ ~H"""
+
+
+
+
+
+ <.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"
+ >
+
+
+ <.icon name="hero-x-mark-solid" class="h-5 w-5" />
+
+
+
+ <%= render_slot(@inner_block) %>
+
+
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders flash notices.
+ ## Examples
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
+ """
+ attr :id, :string, default: "flash", doc: "the optional id of flash container"
+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+ attr :title, :string, default: nil
+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+ slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+ def flash(assigns) do
+ ~H"""
+
hide("##{@id}")}
+ role="alert"
+ class={[
+ "fixed top-2 right-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"
+ ]}
+ {@rest}
+ >
+
+ <.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" />
+ <%= @title %>
+
+
<%= msg %>
+
+ <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
+
+
+ """
+ end
+
+ @doc """
+ Shows the flash group with standard titles and content.
+ ## Examples
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+
+ def flash_group(assigns) do
+ ~H"""
+ <.flash kind={:info} title="Success!" flash={@flash} />
+ <.flash kind={:error} title="Error!" flash={@flash} />
+ <.flash id="client-error" kind={:error} title="We can't find the internet" phx-disconnected={show(".phx-client-error #client-error")} phx-connected={hide("#client-error")} hidden>
+ Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+ <.flash id="server-error" kind={:error} title="Something went wrong!" phx-disconnected={show(".phx-server-error #server-error")} 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" />
+
+ """
+ end
+
+ @doc """
+ Renders a simple form.
+ ## Examples
+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
+ <.input field={@form[:email]} label="Email"/>
+ <.input field={@form[:username]} label="Username" />
+ <:actions>
+ <.button>Save
+
+
+ """
+ attr :for, :any, required: true, doc: "the datastructure for the form"
+ attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
+
+ attr :rest, :global,
+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
+ doc: "the arbitrary HTML attributes to apply to the form tag"
+
+ slot :inner_block, required: true
+ slot :actions, doc: "the slot for form actions, such as a submit button"
+
+ def simple_form(assigns) do
+ ~H"""
+ <.form :let={f} for={@for} as={@as} {@rest}>
+
+ <%= render_slot(@inner_block, f) %>
+
+ <%= render_slot(action, f) %>
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a button.
+ ## Examples
+ <.button>Send!
+ <.button phx-click="go" class="ml-2">Send!
+ """
+ attr :type, :string, default: nil
+ attr :class, :string, default: nil
+ attr :rest, :global, include: ~w(disabled form name value)
+
+ slot :inner_block, required: true
+
+ def button(assigns) do
+ ~H"""
+
+ <%= render_slot(@inner_block) %>
+
+ """
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+ ## Types
+ This function accepts all HTML input types, considering that:
+ * You may also set `type="select"` to render a `
` tag
+ * `type="checkbox"` is used exclusively to render boolean values
+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
+ for more information.
+ ## Examples
+ <.input field={@form[:email]} type="email" />
+ <.input name="my-input" errors={["oh no!"]} />
+ """
+ attr :id, :any, default: nil
+ attr :name, :any
+ attr :label, :string, default: nil
+ attr :value, :any
+
+ attr :type, :string,
+ default: "text",
+ values: ~w(checkbox color date datetime-local email file hidden month number password
+ range radio search select tel text textarea time url week)
+
+ attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
+
+ attr :errors, :list, default: []
+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
+
+ attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
+ multiple pattern placeholder readonly required rows size step)
+
+ slot :inner_block
+
+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
+ assigns
+ |> assign(field: nil, id: assigns.id || field.id)
+ |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
+ |> assign_new(:value, fn -> field.value end)
+ |> input()
+ end
+
+ def input(%{type: "checkbox", value: value} = assigns) do
+ assigns = assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
+
+ ~H"""
+
+
+
+
+ <%= @label %>
+
+ <.error :for={msg <- @errors}><%= msg %>
+
+ """
+ end
+
+ def input(%{type: "select"} = assigns) do
+ ~H"""
+
+ <.label for={@id}><%= @label %>
+
+ <%= @prompt %>
+ <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
+
+ <.error :for={msg <- @errors}><%= msg %>
+
+ """
+ end
+
+ def input(%{type: "textarea"} = assigns) do
+ ~H"""
+
+ <.label for={@id}><%= @label %>
+
+ <.error :for={msg <- @errors}><%= msg %>
+
+ """
+ end
+
+ # All other inputs text, datetime-local, url, password, etc. are handled here...
+ def input(assigns) do
+ ~H"""
+
+ <.label for={@id}><%= @label %>
+
+ <.error :for={msg <- @errors}><%= msg %>
+
+ """
+ end
+
+ @doc """
+ Renders a label.
+ """
+ attr :for, :string, default: nil
+ slot :inner_block, required: true
+
+ def label(assigns) do
+ ~H"""
+
+ <%= render_slot(@inner_block) %>
+
+ """
+ end
+
+ @doc """
+ Generates a generic error message.
+ """
+ slot :inner_block, required: true
+
+ def error(assigns) do
+ ~H"""
+
+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
+ <%= render_slot(@inner_block) %>
+
+ """
+ end
+
+ @doc """
+ Renders a header with title.
+ """
+ attr :class, :string, default: nil
+
+ slot :inner_block, required: true
+ slot :subtitle
+ slot :actions
+
+ def header(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @doc ~S"""
+ Renders a table with generic styling.
+ ## Examples
+ <.table id="users" rows={@users}>
+ <:col :let={user} label="id"><%= user.id %>
+ <:col :let={user} label="username"><%= user.username %>
+
+ """
+ attr :id, :string, required: true
+ attr :rows, :list, required: true
+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
+
+ attr :row_item, :any,
+ default: &Function.identity/1,
+ doc: "the function for mapping each row before calling the :col and :action slots"
+
+ slot :col, required: true do
+ attr :label, :string
+ end
+
+ slot :action, doc: "the slot for showing user actions in the last table column"
+
+ def table(assigns) do
+ assigns =
+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
+ end
+
+ ~H"""
+
+
+
+
+ <%= col[:label] %>
+ <%= gettext("Actions") %>
+
+
+
+
+
+
+
+
+ <%= render_slot(col, @row_item.(row)) %>
+
+
+
+
+
+
+
+ <%= render_slot(action, @row_item.(row)) %>
+
+
+
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a data list.
+ ## Examples
+ <.list>
+ <:item title="Title"><%= @post.title %>
+ <:item title="Views"><%= @post.views %>
+
+ """
+ slot :item, required: true do
+ attr :title, :string, required: true
+ end
+
+ def list(assigns) do
+ ~H"""
+
+
+
+
<%= item.title %>
+ <%= render_slot(item) %>
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a back navigation link.
+ ## Examples
+ <.back navigate={~p"/posts"}>Back to posts
+ """
+ attr :navigate, :any, required: true
+ slot :inner_block, required: true
+
+ def back(assigns) do
+ ~H"""
+
+ <.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) %>
+
+
+ """
+ end
+
+ @doc """
+ Renders a [Heroicon](https://heroicons.com).
+ Heroicons come in three styles – outline, solid, and mini.
+ By default, the outline style is used, but solid and mini may
+ be applied by using the `-solid` and `-mini` suffix.
+ You can customize the size and colors of the icons by setting
+ width, height, and background color classes.
+ Icons are extracted from your `assets/vendor/heroicons` directory and bundled
+ within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
+ ## Examples
+ <.icon name="hero-x-mark-solid" />
+ <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
+ """
+ attr :name, :string, required: true
+ attr :class, :string, default: nil
+
+ def icon(%{name: "hero-" <> _} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ transition: {"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition: {"transition-all transform ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ def show_modal(js \\ %JS{}, id) when is_binary(id) do
+ js
+ |> JS.show(to: "##{id}")
+ |> JS.show(
+ to: "##{id}-bg",
+ transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
+ )
+ |> show("##{id}-container")
+ |> JS.add_class("overflow-hidden", to: "body")
+ |> JS.focus_first(to: "##{id}-content")
+ end
+
+ def hide_modal(js \\ %JS{}, id) do
+ js
+ |> JS.hide(
+ to: "##{id}-bg",
+ transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
+ )
+ |> hide("##{id}-container")
+ |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
+ |> JS.remove_class("overflow-hidden", to: "body")
+ |> JS.pop_focus()
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # When using gettext, we typically pass the strings we want
+ # to translate as a static argument:
+ #
+ # # Translate the number of files with plural rules
+ # dngettext("errors", "1 file", "%{count} files", count)
+ #
+ # However the error messages in our forms and APIs are generated
+ # dynamically, so we need to translate them by calling Gettext
+ # with our gettext backend as first argument. Translations are
+ # available in the errors.po file (as we use the "errors" domain).
+ if count = opts[:count] do
+ Gettext.dngettext(ElixirBoilerplate.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(ElixirBoilerplate.Gettext, "errors", msg, opts)
+ end
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+end
diff --git a/lib/elixir_boilerplate_web/controller.ex b/lib/elixir_boilerplate_web/controller.ex
new file mode 100644
index 00000000..886af04a
--- /dev/null
+++ b/lib/elixir_boilerplate_web/controller.ex
@@ -0,0 +1,15 @@
+defmodule ElixirBoilerplateWeb.Controller do
+ defmacro __using__(_opts) do
+ quote do
+ use Phoenix.Controller,
+ namespace: ElixirBoilerplateWeb,
+ formats: [:html, :json],
+ layouts: [html: ElixirBoilerplateWeb.Layouts]
+
+ import Plug.Conn
+ import ElixirBoilerplate.Gettext
+
+ unquote(ElixirBoilerplateWeb.verified_routes())
+ end
+ end
+end
diff --git a/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex b/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex
new file mode 100644
index 00000000..fb1645d3
--- /dev/null
+++ b/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex
@@ -0,0 +1,29 @@
+defmodule ElixirBoilerplateWeb do
+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+ def html_helpers do
+ quote do
+ # HTML escaping functionality
+ import Phoenix.HTML
+
+ # Core UI components and translation
+ import ElixirBoilerplate.Gettext
+ import ElixirBoilerplateWeb.Components.Core
+
+ # Shortcut for generating JS commands
+ alias Phoenix.LiveView.JS
+
+ # Routes generation with the ~p sigil
+ unquote(verified_routes())
+ end
+ end
+
+ def verified_routes do
+ quote do
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElixirBoilerplateWeb.Endpoint,
+ router: ElixirBoilerplateWeb.Router,
+ statics: ElixirBoilerplateWeb.static_paths()
+ end
+ end
+end
diff --git a/lib/elixir_boilerplate_web/endpoint.ex b/lib/elixir_boilerplate_web/endpoint.ex
index 2d597929..a52106b2 100644
--- a/lib/elixir_boilerplate_web/endpoint.ex
+++ b/lib/elixir_boilerplate_web/endpoint.ex
@@ -7,9 +7,14 @@ defmodule ElixirBoilerplateWeb.Endpoint do
@plug_ssl Plug.SSL.init(rewrite_on: [:x_forwarded_proto], subdomains: true)
socket("/socket", ElixirBoilerplateWeb.Socket)
- socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: {ElixirBoilerplateWeb.Session, :config, []}]])
- plug(ElixirBoilerplateWeb.Plugs.Security)
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [
+ connect_info: [
+ session: {ElixirBoilerplateWeb.Session, :config, []}
+ ]
+ ]
+
plug(:ping)
plug(:canonical_host)
plug(:force_ssl)
@@ -24,7 +29,7 @@ defmodule ElixirBoilerplateWeb.Endpoint do
at: "/",
from: :elixir_boilerplate,
gzip: true,
- only: ~w(assets fonts images favicon.ico robots.txt)
+ only: ElixirBoilerplateWeb.static_paths()
)
# Code reloading can be explicitly enabled under the
@@ -48,9 +53,8 @@ defmodule ElixirBoilerplateWeb.Endpoint do
plug(Plug.MethodOverride)
plug(Plug.Head)
- plug(ElixirBoilerplateHealth.Router)
- plug(ElixirBoilerplateGraphQL.Router)
- plug(:halt_if_sent)
+ plug(:session)
+
plug(ElixirBoilerplateWeb.Router)
@doc """
@@ -115,9 +119,9 @@ defmodule ElixirBoilerplateWeb.Endpoint do
end
end
- # Splitting routers in separate modules has a negative side effect:
- # Phoenix.Router does not check the Plug.Conn state and tries to match the
- # route even if it was already handled/sent by another router.
- defp halt_if_sent(%{state: :sent, halted: false} = conn, _opts), do: halt(conn)
- defp halt_if_sent(conn, _opts), do: conn
+ defp session(conn, _opts) do
+ opts = Plug.Session.init(ElixirBoilerplateWeb.Session.config())
+
+ Plug.Session.call(conn, opts)
+ end
end
diff --git a/lib/elixir_boilerplate_web/errors/error_html.ex b/lib/elixir_boilerplate_web/errors/error_html.ex
new file mode 100644
index 00000000..54288506
--- /dev/null
+++ b/lib/elixir_boilerplate_web/errors/error_html.ex
@@ -0,0 +1,19 @@
+defmodule ElixirBoilerplateWeb.Errors.ErrorHTML do
+ use ElixirBoilerplateWeb.HTML
+
+ # If you want to customize your error pages,
+ # uncomment the embed_templates/1 call below
+ # and add pages to the error directory:
+ #
+ # * lib/elixir_boilerplate_web/controllers/error_html/404.html.heex
+ # * lib/elixir_boilerplate_web/controllers/error_html/500.html.heex
+ #
+ # embed_templates "error_html/*"
+
+ # The default is to render a plain text page based on
+ # the template name. For example, "404.html" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ Phoenix.Controller.status_message_from_template(template)
+ end
+end
diff --git a/lib/elixir_boilerplate_web/errors/error_json.ex b/lib/elixir_boilerplate_web/errors/error_json.ex
new file mode 100644
index 00000000..7d916576
--- /dev/null
+++ b/lib/elixir_boilerplate_web/errors/error_json.ex
@@ -0,0 +1,15 @@
+defmodule ElixirBoilerplateWeb.Errors.ErrorJSON do
+ # If you want to customize a particular status code,
+ # you may add your own clauses, such as:
+ #
+ # def render("500.json", _assigns) do
+ # %{errors: %{detail: "Internal Server Error"}}
+ # end
+
+ # By default, Phoenix returns the status message from
+ # the template name. For example, "404.json" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+ end
+end
diff --git a/lib/elixir_boilerplate_web/errors/templates/404.html.heex b/lib/elixir_boilerplate_web/errors/templates/404.html.heex
index baa990de..7902e3fb 100644
--- a/lib/elixir_boilerplate_web/errors/templates/404.html.heex
+++ b/lib/elixir_boilerplate_web/errors/templates/404.html.heex
@@ -1,8 +1,8 @@
-
-
+
+
Not found
diff --git a/lib/elixir_boilerplate_web/home/html.ex b/lib/elixir_boilerplate_web/home/html.ex
index 615a3b45..fa7493dd 100644
--- a/lib/elixir_boilerplate_web/home/html.ex
+++ b/lib/elixir_boilerplate_web/home/html.ex
@@ -1,13 +1,13 @@
defmodule ElixirBoilerplateWeb.Home.HTML do
- use Phoenix.Component
+ use ElixirBoilerplateWeb.HTML
+
+ alias ElixirBoilerplateWeb.Components.Branding
embed_templates("templates/*")
def render("index.html", assigns), do: index(assigns)
attr(:text, :string, required: true)
+ attr(:class, :string, default: nil)
def message(assigns)
-
- attr(:url, :string, default: "https://github.com/mirego/elixir-boilerplate")
- def header(assigns)
end
diff --git a/lib/elixir_boilerplate_web/home/live.ex b/lib/elixir_boilerplate_web/home/live.ex
index 199d6a71..0e649a82 100644
--- a/lib/elixir_boilerplate_web/home/live.ex
+++ b/lib/elixir_boilerplate_web/home/live.ex
@@ -1,6 +1,5 @@
defmodule ElixirBoilerplateWeb.Home.Live do
- @moduledoc false
- use Phoenix.LiveView, layout: {ElixirBoilerplateWeb.Layouts, :live}
+ use ElixirBoilerplateWeb.LiveView
def mount(_, _, socket) do
socket = assign(socket, :message, "Hello, world!")
@@ -22,7 +21,7 @@ defmodule ElixirBoilerplateWeb.Home.Live do
end
def handle_event("add_flash_success", _, socket) do
- socket = put_flash(socket, :success, "Success: #{DateTime.utc_now()}")
+ socket = put_flash(socket, :info, "Success: #{DateTime.utc_now()}")
{:noreply, socket}
end
diff --git a/lib/elixir_boilerplate_web/home/templates/header.html.heex b/lib/elixir_boilerplate_web/home/templates/header.html.heex
deleted file mode 100644
index ae555aa0..00000000
--- a/lib/elixir_boilerplate_web/home/templates/header.html.heex
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-This repository is the stable base upon which we build our Elixir projects at Mirego. We want to share it with the world so you can build awesome Elixir applications too.
diff --git a/lib/elixir_boilerplate_web/home/templates/index.html.heex b/lib/elixir_boilerplate_web/home/templates/index.html.heex
index 5f3e3743..432ac231 100644
--- a/lib/elixir_boilerplate_web/home/templates/index.html.heex
+++ b/lib/elixir_boilerplate_web/home/templates/index.html.heex
@@ -1,4 +1,18 @@
-
- <.header/>
- <.message text={@message}/>
-
+
+ <.flash_group flash={@flash} />
+
+
+
+
+
+
+
+
+ This repository is the stable base upon which we build our Elixir projects at Mirego.
+ We want to share it with the world so you can build awesome Elixir applications too.
+
+
+ <.message class="pt-8" text={@message} />
+
+
+
diff --git a/lib/elixir_boilerplate_web/home/templates/index_live.html.heex b/lib/elixir_boilerplate_web/home/templates/index_live.html.heex
index 940e3fb8..203d3f9f 100644
--- a/lib/elixir_boilerplate_web/home/templates/index_live.html.heex
+++ b/lib/elixir_boilerplate_web/home/templates/index_live.html.heex
@@ -1,17 +1,29 @@
-
- <.header/>
- <.message text={@message}/>
+
+ <.flash_group flash={@flash} />
-
+
+
+
+
+
-
- -
- <%= @counter %>
- +
-
+
+ This repository is the stable base upon which we build our Elixir projects at Mirego.
+ We want to share it with the world so you can build awesome Elixir applications too.
+
+
+ <.message class="pt-8" text={@message} />
+
-
+
+ <.button type="button" phx-click="decrement_counter">-
+ <%= @counter %>
+ <.button type="button" phx-click="increment_counter">+
+
-
Add flash success
-
Add flash error
-
+
+ <.button phx-click="add_flash_success">Add flash success
+ <.button phx-click="add_flash_error">Add flash error
+
+
+
diff --git a/lib/elixir_boilerplate_web/home/templates/message.html.heex b/lib/elixir_boilerplate_web/home/templates/message.html.heex
index 55f309cf..94e172c7 100644
--- a/lib/elixir_boilerplate_web/home/templates/message.html.heex
+++ b/lib/elixir_boilerplate_web/home/templates/message.html.heex
@@ -1 +1,3 @@
-Message: <%= @text %>
+
+ Message: <%= @text %>
+
diff --git a/lib/elixir_boilerplate_web/html.ex b/lib/elixir_boilerplate_web/html.ex
new file mode 100644
index 00000000..44c19ff9
--- /dev/null
+++ b/lib/elixir_boilerplate_web/html.ex
@@ -0,0 +1,14 @@
+defmodule ElixirBoilerplateWeb.HTML do
+ defmacro __using__(_opts) do
+ quote do
+ use Phoenix.Component
+
+ # Import convenience functions from controllers
+ import Phoenix.Controller,
+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+ # Include general helpers for rendering HTML
+ unquote(ElixirBoilerplateWeb.html_helpers())
+ end
+ end
+end
diff --git a/lib/elixir_boilerplate_web/layouts/layouts.ex b/lib/elixir_boilerplate_web/layouts/layouts.ex
index cd2164b5..917f05b6 100644
--- a/lib/elixir_boilerplate_web/layouts/layouts.ex
+++ b/lib/elixir_boilerplate_web/layouts/layouts.ex
@@ -1,16 +1,8 @@
defmodule ElixirBoilerplateWeb.Layouts do
- @moduledoc false
- use Phoenix.Component
-
- alias ElixirBoilerplateWeb.Router.Helpers, as: Routes
- alias Phoenix.LiveView.JS
+ use ElixirBoilerplateWeb.HTML
embed_templates("templates/*")
- attr(:flash, :map, required: true)
- attr(:kind, :atom, required: true)
- def flash(assigns)
-
def hide_flash(id) do
"lv:clear-flash"
|> JS.push()
diff --git a/lib/elixir_boilerplate_web/layouts/templates/app.html.heex b/lib/elixir_boilerplate_web/layouts/templates/app.html.heex
index 0d09a6c5..ff3178bf 100644
--- a/lib/elixir_boilerplate_web/layouts/templates/app.html.heex
+++ b/lib/elixir_boilerplate_web/layouts/templates/app.html.heex
@@ -1,9 +1,3 @@
-
- <.flash flash={@flash} kind={:success} />
- <.flash flash={@flash} kind={:error} />
- <.flash flash={@flash} kind={:info} />
-
-
<%= @inner_content %>
diff --git a/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex b/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex
deleted file mode 100644
index 6f2af775..00000000
--- a/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex
+++ /dev/null
@@ -1,8 +0,0 @@
- to_string(@kind)}
- phx-click={hide_flash("#" <> "flash-" <> to_string(@kind))}
- phx-hook="Flash"
->
- <%= msg %>
-
diff --git a/lib/elixir_boilerplate_web/layouts/templates/live.html.heex b/lib/elixir_boilerplate_web/layouts/templates/live.html.heex
index 0d09a6c5..d2cdafd8 100644
--- a/lib/elixir_boilerplate_web/layouts/templates/live.html.heex
+++ b/lib/elixir_boilerplate_web/layouts/templates/live.html.heex
@@ -1,8 +1,6 @@
- <.flash flash={@flash} kind={:success} />
- <.flash flash={@flash} kind={:error} />
- <.flash flash={@flash} kind={:info} />
+ <.flash_group flash={@flash} />
<%= @inner_content %>
diff --git a/lib/elixir_boilerplate_web/layouts/templates/root.html.heex b/lib/elixir_boilerplate_web/layouts/templates/root.html.heex
index 3100e3c8..623935bc 100644
--- a/lib/elixir_boilerplate_web/layouts/templates/root.html.heex
+++ b/lib/elixir_boilerplate_web/layouts/templates/root.html.heex
@@ -2,14 +2,19 @@
-
-
-
-
-
+
+
+
+ <.live_title suffix=" · ElixirBoilerplate">
+ <%= assigns[:page_title] || "ElixirBoilerplate" %>
+
+
+
+
-
+
<%= @inner_content %>
diff --git a/lib/elixir_boilerplate_web/live_view.ex b/lib/elixir_boilerplate_web/live_view.ex
new file mode 100644
index 00000000..4c3b211a
--- /dev/null
+++ b/lib/elixir_boilerplate_web/live_view.ex
@@ -0,0 +1,9 @@
+defmodule ElixirBoilerplateWeb.LiveView do
+ defmacro __using__(_opts) do
+ quote do
+ use Phoenix.LiveView, layout: {ElixirBoilerplateWeb.Layouts, :live}
+
+ unquote(ElixirBoilerplateWeb.html_helpers())
+ end
+ end
+end
diff --git a/lib/elixir_boilerplate_web/plugs/security.ex b/lib/elixir_boilerplate_web/plugs/security.ex
index 315c55dd..a95237fe 100644
--- a/lib/elixir_boilerplate_web/plugs/security.ex
+++ b/lib/elixir_boilerplate_web/plugs/security.ex
@@ -33,10 +33,17 @@ defmodule ElixirBoilerplateWeb.Plugs.Security do
defp media_src_directive, do: "'self'"
defp font_src_directive, do: "'self'"
defp connect_src_directive, do: "'self'"
- defp style_src_directive, do: "'self' 'unsafe-inline'"
defp frame_src_directive, do: "'self'"
defp image_src_directive, do: "'self' data:"
+ defp style_src_directive do
+ if Application.get_env(:elixir_boilerplate, __MODULE__)[:allow_unsafe_scripts] do
+ "'self' 'unsafe-inline'"
+ else
+ "'self'"
+ end
+ end
+
defp script_src_directive do
if Application.get_env(:elixir_boilerplate, __MODULE__)[:allow_unsafe_scripts] do
"'self' 'unsafe-eval' 'unsafe-inline'"
diff --git a/lib/elixir_boilerplate_web/router.ex b/lib/elixir_boilerplate_web/router.ex
index 06e3affd..8313de0b 100644
--- a/lib/elixir_boilerplate_web/router.ex
+++ b/lib/elixir_boilerplate_web/router.ex
@@ -1,6 +1,9 @@
defmodule ElixirBoilerplateWeb.Router do
- use Phoenix.Router
+ use Phoenix.Router, helpers: false
+ # Import common connection and controller functions to use in pipelines
+ import Plug.Conn
+ import Phoenix.Controller
import Phoenix.LiveView.Router
pipeline :browser do
@@ -11,20 +14,24 @@ defmodule ElixirBoilerplateWeb.Router do
json_decoder: Phoenix.json_library()
)
- plug(:accepts, ["html", "json"])
+ plug(:accepts, ~w[html json])
- plug(:session)
plug(:fetch_session)
+ plug(:fetch_live_flash)
plug(:protect_from_forgery)
- plug(:fetch_live_flash)
+ plug(ElixirBoilerplateWeb.Plugs.Security)
plug(:put_layout, {ElixirBoilerplateWeb.Layouts, :app})
plug(:put_root_layout, {ElixirBoilerplateWeb.Layouts, :root})
end
+ pipeline :api do
+ plug(:accepts, ~w[json])
+ end
+
scope "/" do
- pipe_through(:browser)
+ pipe_through :browser
# To enable metrics dashboard use `telemetry_ui_allowed: true` as assigns value
#
@@ -34,22 +41,41 @@ defmodule ElixirBoilerplateWeb.Router do
end
scope "/", ElixirBoilerplateWeb do
- pipe_through(:browser)
+ pipe_through :browser
get("/", Home.Controller, :index, as: :home)
+ live("/live", Home.Live, :index, as: :live_home)
end
- scope "/", ElixirBoilerplateWeb do
- pipe_through(:browser)
+ scope "/api", ElixirBoilerplateWeb.Api do
+ pipe_through :api
- live("/live", Home.Live, :index, as: :live_home)
+ get("/version", Version.Controller, :index, as: :version)
end
- # The session will be stored in the cookie and signed,
- # this means its contents can be read but not tampered with.
- # Set :encryption_salt if you would also like to encrypt it.
- defp session(conn, _opts) do
- opts = Plug.Session.init(ElixirBoilerplateWeb.Session.config())
- Plug.Session.call(conn, opts)
+ scope "/" do
+ pipe_through :api
+
+ forward("/graphql", Absinthe.Plug, schema: ElixirBoilerplateGraphQL.Schema)
+
+ if Mix.env() == :dev do
+ forward("/graphiql", Absinthe.Plug.GraphiQL,
+ schema: ElixirBoilerplateGraphQL.Schema,
+ socket: ElixirBoilerplateWeb.Socket,
+ interface: :playground
+ )
+ end
end
+
+ forward(
+ "/health",
+ PlugCheckup,
+ PlugCheckup.Options.new(
+ json_encoder: Phoenix.json_library(),
+ checks: ElixirBoilerplateHealth.checks(),
+ error_code: ElixirBoilerplateHealth.error_code(),
+ timeout: :timer.seconds(5),
+ pretty: false
+ )
+ )
end
diff --git a/lib/elixir_boilerplate_web/session.ex b/lib/elixir_boilerplate_web/session.ex
index 643a721b..08e0d922 100644
--- a/lib/elixir_boilerplate_web/session.ex
+++ b/lib/elixir_boilerplate_web/session.ex
@@ -9,6 +9,6 @@ defmodule ElixirBoilerplateWeb.Session do
end
defp app_config(key) do
- Keyword.fetch!(Application.get_env(:elixir_boilerplate, ElixirBoilerplateWeb.Endpoint), key)
+ Keyword.fetch!(Application.get_env(:elixir_boilerplate, __MODULE__), key)
end
end
diff --git a/mix.exs b/mix.exs
index 6503f4c8..963cc71e 100644
--- a/mix.exs
+++ b/mix.exs
@@ -5,8 +5,8 @@ defmodule ElixirBoilerplate.Mixfile do
[
app: :elixir_boilerplate,
version: "0.0.1",
- erlang: "~> 25.0",
- elixir: "~> 1.13",
+ erlang: "~> 26.0",
+ elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
test_paths: ["test"],
test_pattern: "**/*_test.exs",
@@ -32,10 +32,10 @@ defmodule ElixirBoilerplate.Mixfile do
defp aliases do
[
- "assets.deploy": [
- "esbuild default --minify",
- "phx.digest"
- ],
+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+ "assets.build": ["tailwind default", "esbuild default"],
+ "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate", "test"]
@@ -46,6 +46,7 @@ defmodule ElixirBoilerplate.Mixfile do
[
# Assets bundling
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
+ {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
# HTTP Client
{:hackney, "~> 1.18"},
diff --git a/mix.lock b/mix.lock
index 14c8bb38..f5420411 100644
--- a/mix.lock
+++ b/mix.lock
@@ -39,6 +39,7 @@
"new_relic_absinthe": {:hex, :new_relic_absinthe, "0.0.4", "57917f99789d9b36e4beb599deba495a474e5bf99a5c70a33717b0e17f1c5d4d", [:mix], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:new_relic_agent, "~> 1.19", [hex: :new_relic_agent, repo: "hexpm", optional: false]}], "hexpm", "6b796662e550ddd07e98ff3df95803a6b2a023605e78e0a45261d3e66341c296"},
"new_relic_agent": {:hex, :new_relic_agent, "1.28.0", "eb015edb4f4887a31ee0488cf9ce97b7e46c9e0208eeff8737d8ea09cd506d09", [:mix], [{:castore, ">= 0.1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ecto, ">= 3.4.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, ">= 3.4.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.10.4", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.4.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:redix, ">= 0.11.0", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef220f429f2d673e78679ec96cd4e8979b2cb2166b204bfe1a43ca974520743a"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
+ "observer_cli": {:hex, :observer_cli, "1.7.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [: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", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
@@ -55,11 +56,13 @@
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
"postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
+ "recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"},
"sentry": {:hex, :sentry, "9.1.0", "8689b85774003ddcebfd9d48a93bc3f3bf72223983514521aa30645c6f204f86", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "d70c88ab0c6a511594856ae2244d1bd70b8b7a4a42201a3569880f1dd2a3adec"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"styler": {:hex, :styler, "0.11.6", "ad4c5fc1ff72b93107ecb251f595b316c6d97604b742f3473aa036888592b270", [:mix], [], "hexpm", "0b0b9936e91b01a7a9fd7239902581ed1cb5515254357126429a37d1bb3d0078"},
"table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"},
+ "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
diff --git a/test/elixir_boilerplate_web/errors/error_html_test.exs b/test/elixir_boilerplate_web/errors/error_html_test.exs
new file mode 100644
index 00000000..f0a83732
--- /dev/null
+++ b/test/elixir_boilerplate_web/errors/error_html_test.exs
@@ -0,0 +1,17 @@
+# credo:disable-for-this-line CredoNaming.Check.Consistency.ModuleFilename
+defmodule ElixirBoilerplateWeb.Errors.ErrorHtmlTest do
+ use ElixirBoilerplate.DataCase, async: true
+
+ # Bring render_to_string/3 for testing custom views
+ import Phoenix.Template
+
+ alias ElixirBoilerplateWeb.Errors.ErrorHTML
+
+ test "renders 404.html" do
+ assert render_to_string(ErrorHTML, "404", "html", []) == "Not Found"
+ end
+
+ test "renders 500.html" do
+ assert render_to_string(ErrorHTML, "500", "html", []) == "Internal Server Error"
+ end
+end
diff --git a/test/elixir_boilerplate_web/errors/error_json_test.exs b/test/elixir_boilerplate_web/errors/error_json_test.exs
new file mode 100644
index 00000000..27814a14
--- /dev/null
+++ b/test/elixir_boilerplate_web/errors/error_json_test.exs
@@ -0,0 +1,14 @@
+# credo:disable-for-this-file CredoNaming.Check.Consistency.ModuleFilename
+defmodule ElixirBoilerplateWeb.Errors.ErrorJsonTest do
+ use ElixirBoilerplate.DataCase, async: true
+
+ alias ElixirBoilerplateWeb.Errors.ErrorJSON
+
+ test "renders 404" do
+ assert ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+ end
+
+ test "renders 500" do
+ assert ErrorJSON.render("500.json", %{}) == %{errors: %{detail: "Internal Server Error"}}
+ end
+end
diff --git a/test/elixir_boilerplate_web/errors_test.exs b/test/elixir_boilerplate_web/errors/errors_test.exs
similarity index 96%
rename from test/elixir_boilerplate_web/errors_test.exs
rename to test/elixir_boilerplate_web/errors/errors_test.exs
index 1c407caa..d2be3886 100644
--- a/test/elixir_boilerplate_web/errors_test.exs
+++ b/test/elixir_boilerplate_web/errors/errors_test.exs
@@ -1,8 +1,6 @@
defmodule ElixirBoilerplateWeb.ErrorsTest do
use ElixirBoilerplate.DataCase, async: true
- alias ElixirBoilerplateWeb.Errors
-
defmodule UserRole do
@moduledoc false
use Ecto.Schema
@@ -76,7 +74,7 @@ defmodule ElixirBoilerplateWeb.ErrorsTest do
defp changeset_to_error_messages(changeset) do
changeset
- |> Errors.changeset_to_error_messages()
+ |> ElixirBoilerplateWeb.Errors.changeset_to_error_messages()
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
end