feat: migration to phoenix 1.8 - merge changed files

This commit is contained in:
Moritz 2025-07-09 20:04:26 +02:00
parent 50832da885
commit 0334260de5
Signed by: moritz
GPG key ID: 1020A035E5DD0824
13 changed files with 233 additions and 453 deletions

View file

@ -5,7 +5,6 @@
@source "../css"; @source "../css";
@source "../js"; @source "../js";
@source "../../lib/mv_web"; @source "../../lib/mv_web";
@source "../../deps/ash_authentication_phoenix";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available. /* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */ The heroicons installation itself is managed by your mix.exs */

View file

@ -76,25 +76,24 @@ config :esbuild,
version: "0.17.11", version: "0.17.11",
mv: [ mv: [
args: args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]
# Configure tailwind (the version is required) # Configure tailwind (the version is required)
config :tailwind, config :tailwind,
version: "3.4.3", version: "4.0.9",
mv: [ mv: [
args: ~w( args: ~w(
--config=tailwind.config.js --input=assets/css/app.css
--input=css/app.css --output=priv/static/assets/css/app.css
--output=../priv/static/assets/app.css
), ),
cd: Path.expand("../assets", __DIR__) cd: Path.expand("..", __DIR__)
] ]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",
metadata: [:request_id] metadata: [:request_id]

View file

@ -17,10 +17,10 @@ config :mv, Mv.Repo,
# The watchers configuration can be used to run external # The watchers configuration can be used to run external
# watchers to your application. For example, we can use it # watchers to your application. For example, we can use it
# to bundle .js and .css sources. # to bundle .js and .css sources.
# Binding to loopback ipv4 address prevents access from other machines.
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000], http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: false, check_origin: false,
code_reloader: true, code_reloader: true,
debug_errors: true, debug_errors: true,
@ -56,10 +56,11 @@ config :mv, MvWeb.Endpoint,
# Watch static and templates for browser reloading. # Watch static and templates for browser reloading.
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
live_reload: [ live_reload: [
web_console_logger: true,
patterns: [ patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/mv_web/(controllers|live|components)/.*(ex|heex)$" ~r"lib/mv_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
] ]
] ]
@ -67,7 +68,7 @@ config :mv, MvWeb.Endpoint,
config :mv, dev_routes: true config :mv, dev_routes: true
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n" config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such # Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive. # in production as building large stacktraces may be expensive.
@ -77,7 +78,8 @@ config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view, config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup # Include HEEx debug annotations as HTML comments in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true, debug_heex_annotations: true,
# Enable helpful, but potentially expensive runtime checks # Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true enable_expensive_runtime_checks: true

View file

@ -116,7 +116,7 @@ if config_env() == :prod do
# domain: System.get_env("MAILGUN_DOMAIN") # domain: System.get_env("MAILGUN_DOMAIN")
# #
# For this example you need include a HTTP client required by Swoosh API client. # For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box: # Swoosh supports Hackney, Req and Finch out of the box:
# #
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
# #

View file

@ -12,8 +12,6 @@ defmodule Mv.Application do
Mv.Repo, Mv.Repo,
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub}, {Phoenix.PubSub, name: Mv.PubSub},
# Start the Finch HTTP client for sending emails
{Finch, name: Mv.Finch},
{AshAuthentication.Supervisor, otp_app: :my}, {AshAuthentication.Supervisor, otp_app: :my},
# Start a worker by calling: Mv.Worker.start_link(arg) # Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg}, # {Mv.Worker, arg},

View file

@ -38,9 +38,7 @@ defmodule MvWeb do
def controller do def controller do
quote do quote do
use Phoenix.Controller, use Phoenix.Controller, formats: [:html, :json]
formats: [:html, :json],
layouts: [html: MvWeb.Layouts]
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@ -52,10 +50,10 @@ defmodule MvWeb do
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView
layout: {MvWeb.Layouts, :app}
on_mount MvWeb.LiveHelpers on_mount MvWeb.LiveHelpers
unquote(html_helpers()) unquote(html_helpers())
end end
end end
@ -91,8 +89,9 @@ defmodule MvWeb do
# Core UI components # Core UI components
import MvWeb.CoreComponents import MvWeb.CoreComponents
# Shortcut for generating JS commands # Common modules used in templates
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
alias MvWeb.Layouts
# Routes generation with the ~p sigil # Routes generation with the ~p sigil
unquote(verified_routes()) unquote(verified_routes())

View file

@ -3,92 +3,34 @@ defmodule MvWeb.CoreComponents do
Provides core UI components. Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as modals, tables, and core building blocks for your application, such as tables, forms, and
forms. The components consist mostly of markup and are well-documented inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs. them in any way you want, based on your application growth and needs.
The default components use Tailwind CSS, a utility-first CSS framework. The foundation for styling is Tailwind CSS, a utility-first CSS framework,
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn augmented with daisyUI, a Tailwind CSS plugin that provides UI components
how to customize them or feel free to swap in another framework altogether. and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
""" """
use Phoenix.Component use Phoenix.Component
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
This is a modal.
</.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.
</.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"""
<div
id={@id}
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden"
>
<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"
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={gettext("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>
</div>
</div>
</div>
</div>
"""
end
@doc """ @doc """
Renders flash notices. Renders flash notices.
@ -114,132 +56,59 @@ defmodule MvWeb.CoreComponents do
id={@id} id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class={[ class="toast toast-top toast-end z-50"
"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"
]}
{@rest} {@rest}
> >
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6"> <div class={[
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> @kind == :info && "alert-info",
{@title} @kind == :error && "alert-error"
</p> ]}>
<p class="mt-2 text-sm leading-5">{msg}</p> <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" /> <div>
</button> <p :if={@title} class="font-semibold">{@title}</p>
</div> <p>{msg}</p>
"""
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"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id}>
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error")}
phx-connected={hide("#client-error")}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error")}
phx-connected={hide("#server-error")}
hidden
>
{gettext("Hang in there while we get back on track")}
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
</div>
"""
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</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, required: true, doc: "the data structure 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}>
<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> </div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div> </div>
</.form> </div>
""" """
end end
@doc """ @doc """
Renders a button. Renders a button with navigation support.
## Examples ## Examples
<.button>Send!</.button> <.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button> <.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
""" """
attr :type, :string, default: nil attr :rest, :global, include: ~w(href navigate patch method)
attr :class, :string, default: nil attr :variant, :string, values: ~w(primary)
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true slot :inner_block, required: true
def button(assigns) do def button(%{rest: rest} = assigns) do
~H""" variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
<button assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
type={@type}
class={[ if rest[:href] || rest[:navigate] || rest[:patch] do
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3", ~H"""
"text-sm font-semibold leading-6 text-white active:text-white/80", <.link class={["btn", @class]} {@rest}>
@class {render_slot(@inner_block)}
]} </.link>
{@rest} """
> else
{render_slot(@inner_block)} ~H"""
</button> <button class={["btn", @class]} {@rest}>
""" {render_slot(@inner_block)}
</button>
"""
end
end end
@doc """ @doc """
@ -276,7 +145,7 @@ defmodule MvWeb.CoreComponents do
attr :type, :string, attr :type, :string,
default: "text", default: "text",
values: ~w(checkbox color date datetime-local email file month number password values: ~w(checkbox color date datetime-local email file month number password
range search select tel text textarea time url week) search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField, attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]" doc: "a form field struct retrieved from the form, for example: @form[:email]"
@ -286,6 +155,8 @@ defmodule MvWeb.CoreComponents do
attr :prompt, :string, default: nil, doc: "the prompt for select 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 :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 :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :string, default: nil, doc: "the input class to use over defaults"
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global, attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
@ -309,108 +180,95 @@ defmodule MvWeb.CoreComponents do
end) end)
~H""" ~H"""
<div> <fieldset class="fieldset mb-2">
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600"> <label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<input <span class="label">
type="checkbox" <input
id={@id} type="checkbox"
name={@name} id={@id}
value="true" name={@name}
checked={@checked} value="true"
class="rounded border-zinc-300 text-zinc-900 focus:ring-0" checked={@checked}
{@rest} class={@class || "checkbox checkbox-sm"}
/> {@rest}
{@label} />{@label}
</span>
</label> </label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
</div> </fieldset>
""" """
end end
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
~H""" ~H"""
<div> <fieldset class="fieldset mb-2">
<.label for={@id}>{@label}</.label> <label>
<select <span :if={@label} class="label mb-1">{@label}</span>
id={@id} <select
name={@name} id={@id}
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" name={@name}
multiple={@multiple} class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
{@rest} multiple={@multiple}
> {@rest}
<option :if={@prompt} value="">{@prompt}</option> >
{Phoenix.HTML.Form.options_for_select(@options, @value)} <option :if={@prompt} value="">{@prompt}</option>
</select> {Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
</div> </fieldset>
""" """
end end
def input(%{type: "textarea"} = assigns) do def input(%{type: "textarea"} = assigns) do
~H""" ~H"""
<div> <fieldset class="fieldset mb-2">
<.label for={@id}>{@label}</.label> <label>
<textarea <span :if={@label} class="label mb-1">{@label}</span>
id={@id} <textarea
name={@name} id={@id}
class={[ name={@name}
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]", class={[
@errors == [] && "border-zinc-300 focus:border-zinc-400", @class || "w-full textarea",
@errors != [] && "border-rose-400 focus:border-rose-400" @errors != [] && (@error_class || "textarea-error")
]} ]}
{@rest} {@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea> >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
</div> </fieldset>
""" """
end end
# All other inputs text, datetime-local, url, password, etc. are handled here... # All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do def input(assigns) do
~H""" ~H"""
<div> <fieldset class="fieldset mb-2">
<.label for={@id}>{@label}</.label> <label>
<input <span :if={@label} class="label mb-1">{@label}</span>
type={@type} <input
name={@name} type={@type}
id={@id} name={@name}
value={Phoenix.HTML.Form.normalize_value(@type, @value)} id={@id}
class={[ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6", class={[
@errors == [] && "border-zinc-300 focus:border-zinc-400", @class || "w-full input",
@errors != [] && "border-rose-400 focus:border-rose-400" @errors != [] && (@error_class || "input-error")
]} ]}
{@rest} {@rest}
/> />
</label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
</div> </fieldset>
""" """
end end
@doc """ # Helper used by inputs to generate form errors
Renders a label. defp error(assigns) do
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H""" ~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800"> <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
{render_slot(@inner_block)} <.icon name="hero-exclamation-circle" class="size-5" />
</label>
"""
end
@doc """
Generates a generic error message.
"""
slot :inner_block, required: true
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" />
{render_slot(@inner_block)} {render_slot(@inner_block)}
</p> </p>
""" """
@ -427,12 +285,12 @@ defmodule MvWeb.CoreComponents do
def header(assigns) do def header(assigns) do
~H""" ~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}> <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
<div> <div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800"> <h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</h1> </h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> <p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)} {render_slot(@subtitle)}
</p> </p>
</div> </div>
@ -473,49 +331,34 @@ defmodule MvWeb.CoreComponents do
end end
~H""" ~H"""
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0"> <table class="table table-zebra">
<table class="w-[40rem] mt-11 sm:w-full"> <thead>
<thead class="text-sm text-left leading-6 text-zinc-500"> <tr>
<tr> <th :for={col <- @col}>{col[:label]}</th>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th> <th :if={@action != []}>
<th :if={@action != []} class="relative p-0 pb-4"> <span class="sr-only">{gettext("Actions")}</span>
<span class="sr-only">{gettext("Actions")}</span> </th>
</th> </tr>
</tr> </thead>
</thead> <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tbody <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
id={@id} <td
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} :for={col <- @col}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700" phx-click={@row_click && @row_click.(row)}
> class={@row_click && "hover:cursor-pointer"}
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50"> >
<td {render_slot(col, @row_item.(row))}
:for={{col, i} <- Enum.with_index(@col)} </td>
phx-click={@row_click && @row_click.(row)} <td :if={@action != []} class="w-0 font-semibold">
class={["relative p-0", @row_click && "hover:cursor-pointer"]} <div class="flex gap-4">
> <%= for action <- @action do %>
<div class="block py-4 pr-6"> {render_slot(action, @row_item.(row))}
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" /> <% end %>
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}> </div>
{render_slot(col, @row_item.(row))} </td>
</span> </tr>
</div> </tbody>
</td> </table>
<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>
""" """
end end
@ -535,38 +378,14 @@ defmodule MvWeb.CoreComponents do
def list(assigns) do def list(assigns) do
~H""" ~H"""
<div class="mt-14"> <ul class="list">
<dl class="-my-4 divide-y divide-zinc-100"> <li :for={item <- @item} class="list-row">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> <div>
<dt class="w-1/4 flex-none text-zinc-500">{item.title}</dt> <div class="font-bold">{item.title}</div>
<dd class="text-zinc-700">{render_slot(item)}</dd> <div>{render_slot(item)}</div>
</div> </div>
</dl> </li>
</div> </ul>
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts</.back>
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
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)}
</.link>
</div>
""" """
end end
@ -581,15 +400,15 @@ defmodule MvWeb.CoreComponents do
width, height, and background color classes. width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in your `assets/tailwind.config.js`. your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples ## Examples
<.icon name="hero-x-mark-solid" /> <.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
""" """
attr :name, :string, required: true attr :name, :string, required: true
attr :class, :string, default: nil attr :class, :string, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do def icon(%{name: "hero-" <> _} = assigns) do
~H""" ~H"""
@ -604,7 +423,7 @@ defmodule MvWeb.CoreComponents do
to: selector, to: selector,
time: 300, time: 300,
transition: transition:
{"transition-all transform ease-out duration-300", {"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"} "opacity-100 translate-y-0 sm:scale-100"}
) )
@ -615,37 +434,11 @@ defmodule MvWeb.CoreComponents do
to: selector, to: selector,
time: 200, time: 200,
transition: transition:
{"transition-all transform ease-in duration-200", {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
) )
end end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
time: 300,
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 """ @doc """
Translates an error message using gettext. Translates an error message using gettext.
""" """

View file

@ -40,6 +40,15 @@ defmodule MvWeb.Layouts do
</div> </div>
<div class="flex-none"> <div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center"> <ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<form method="post" action="/set_locale" class="mr-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select name="locale" onchange="this.form.submit()" class="select select-sm">
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
</li>
<li> <li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a> <a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li> </li>

View file

@ -1,39 +0,0 @@
<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">
<form method="post" action="/set_locale" class="mr-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select name="locale" onchange="this.form.submit()" class="rounded border px-2 py-1">
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<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">&rarr;</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>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]"> <html lang="en">
<head> <head>
{Application.get_env(:live_debugger, :live_debugger_tags)} {Application.get_env(:live_debugger, :live_debugger_tags)}
@ -9,11 +9,29 @@
<.live_title default="Mv" suffix=" · Phoenix Framework"> <.live_title default="Mv" suffix=" · Phoenix Framework">
{assigns[:page_title]} {assigns[:page_title]}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
})();
</script> </script>
</head> </head>
<body class="bg-white"> <body>
{@inner_content} {@inner_content}
</body> </body>
</html> </html>

View file

@ -17,12 +17,13 @@ defmodule MvWeb.Endpoint do
# Serve at "/" the static files from "priv/static" directory. # Serve at "/" the static files from "priv/static" directory.
# #
# You should set gzip to true if you are running phx.digest # When code reloading is disabled (e.g., in production),
# when deploying your static files in production. # the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static, plug Plug.Static,
at: "/", at: "/",
from: :mv, from: :mv,
gzip: false, gzip: not code_reloading?,
only: MvWeb.static_paths() only: MvWeb.static_paths()
if Code.ensure_loaded?(Tidewave) do if Code.ensure_loaded?(Tidewave) do

19
mix.exs
View file

@ -5,12 +5,13 @@ defmodule Mv.MixProject do
[ [
app: :mv, app: :mv,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.14", elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
consolidate_protocols: Mix.env() != :dev, consolidate_protocols: Mix.env() != :dev,
aliases: aliases(), aliases: aliases(),
deps: deps() deps: deps(),
listeners: [Phoenix.CodeReloader]
] ]
end end
@ -44,26 +45,26 @@ defmodule Mv.MixProject do
{:ash_authentication, "~> 4.9"}, {:ash_authentication, "~> 4.9"},
{:ash_authentication_phoenix, "~> 2.10"}, {:ash_authentication_phoenix, "~> 2.10"},
{:igniter, "~> 0.6", only: [:dev, :test]}, {:igniter, "~> 0.6", only: [:dev, :test]},
{:phoenix, "~> 1.7.20"}, {:phoenix, "~> 1.8.0-rc.3", override: true},
{:phoenix_ecto, "~> 4.5"}, {:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"}, {:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"}, {:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.0.0"}, {:phoenix_live_view, "~> 1.0.9"},
{:floki, ">= 0.30.0", only: :test}, {:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"}, {:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons, {:heroicons,
github: "tailwindlabs/heroicons", github: "tailwindlabs/heroicons",
tag: "v2.2.0", tag: "v2.1.1",
sparse: "optimized", sparse: "optimized",
app: false, app: false,
compile: false, compile: false,
depth: 1}, depth: 1},
{:swoosh, "~> 1.5"}, {:swoosh, "~> 1.16"},
{:finch, "~> 0.20"}, {:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"}, {:gettext, "~> 0.26"},

View file

@ -30,7 +30,7 @@
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"igniter": {:hex, :igniter, "0.6.19", "d87703b36890bc4278341d966a7ed8e10604a18610a4331ac10c75d1af48fff4", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "c2070b3fdbd238fc0a0bfbc1f125b5c0f79a1fe2f5b3c7b43cd33de696783663"}, "igniter": {:hex, :igniter, "0.6.19", "d87703b36890bc4278341d966a7ed8e10604a18610a4331ac10c75d1af48fff4", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "c2070b3fdbd238fc0a0bfbc1f125b5c0f79a1fe2f5b3c7b43cd33de696783663"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
@ -47,7 +47,7 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"},
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [: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", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix": {:hex, :phoenix, "1.8.0-rc.3", "6ae19e57b9c109556f1b8abdb992d96d443b0ae28e03b604f3dc6c75d9f7d35f", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "419422afc33e965c0dbf181cbedc77b4cfd024dac0db7d9d2287656043d48e24"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},