Compare commits

...

3 commits

Author SHA1 Message Date
646c5e2bcb
feat: migration to phoenix 1.8 - merge old live views into new live views
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-09 21:17:04 +02:00
fd6770e82b
feat: migration to phoenix 1.8 - generate new ash live views 2025-07-09 21:08:49 +02:00
731fe2258e
feat: migration to phoenix 1.8 - merge changed files 2025-07-09 21:08:48 +02:00
31 changed files with 845 additions and 1177 deletions

View file

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

View file

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

View file

@ -17,10 +17,10 @@ config :mv, Mv.Repo,
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
# Binding to loopback ipv4 address prevents access from other machines.
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.
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,
code_reloader: true,
debug_errors: true,
@ -56,10 +56,11 @@ config :mv, MvWeb.Endpoint,
# Watch static and templates for browser reloading.
config :mv, MvWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~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
# 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
# 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_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,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true

View file

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

View file

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

View file

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

View file

@ -3,92 +3,34 @@ defmodule MvWeb.CoreComponents do
Provides core UI components.
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
forms. The components consist mostly of markup and are well-documented
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are 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.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
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 Gettext, backend: MvWeb.Gettext
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 """
Renders flash notices.
@ -114,132 +56,59 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={[
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
class="toast toast-top toast-end z-50"
{@rest}
>
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
{@title}
</p>
<p class="mt-2 text-sm leading-5">{msg}</p>
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
"""
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 class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</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>
</.form>
</div>
"""
end
@doc """
Renders a button.
Renders a button with navigation support.
## Examples
<.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 :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={["btn", @class]} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={["btn", @class]} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
@ -276,7 +145,7 @@ defmodule MvWeb.CoreComponents do
attr :type, :string,
default: "text",
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,
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 :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 :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,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
@ -309,108 +180,95 @@ defmodule MvWeb.CoreComponents do
end)
~H"""
<div>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<fieldset class="fieldset mb-2">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
{@label}
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
</fieldset>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<select
id={@id}
name={@name}
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
</fieldset>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
</fieldset>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
</fieldset>
"""
end
@doc """
Renders a label.
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
{render_slot(@inner_block)}
</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" />
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
@ -427,12 +285,12 @@ defmodule MvWeb.CoreComponents do
def header(assigns) do
~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>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</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)}
</p>
</div>
@ -473,49 +331,34 @@ defmodule MvWeb.CoreComponents do
end
~H"""
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
<table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
<th :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
<td :if={@action != []} class="relative w-14 p-0">
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
<span
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
{render_slot(action, @row_item.(row))}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@ -535,38 +378,14 @@ defmodule MvWeb.CoreComponents do
def list(assigns) do
~H"""
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{item.title}</dt>
<dd class="text-zinc-700">{render_slot(item)}</dd>
<ul class="list">
<li :for={item <- @item} class="list-row">
<div>
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</dl>
</div>
"""
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>
</li>
</ul>
"""
end
@ -581,15 +400,15 @@ defmodule MvWeb.CoreComponents do
width, height, and background color classes.
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
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: nil
attr :class, :string, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
@ -604,7 +423,7 @@ defmodule MvWeb.CoreComponents do
to: selector,
time: 300,
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-100 translate-y-0 sm:scale-100"}
)
@ -615,37 +434,11 @@ defmodule MvWeb.CoreComponents do
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
{"transition-all 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",
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 """
Translates an error message using gettext.
"""

View file

@ -40,6 +40,15 @@ defmodule MvWeb.Layouts do
</div>
<div class="flex-none">
<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>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</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>
<html lang="en" class="[scrollbar-gutter:stable]">
<html lang="en">
<head>
{Application.get_env(:live_debugger, :live_debugger_tags)}
@ -9,11 +9,29 @@
<.live_title default="Mv" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<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>
</head>
<body class="bg-white">
<body>
{@inner_content}
</body>
</html>

View file

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

View file

@ -1,43 +1,18 @@
defmodule MvWeb.MemberLive.FormComponent do
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, property_types} = Mv.Membership.list_property_types()
initial_properties =
Enum.map(property_types, fn pt ->
%{
"property_type_id" => pt.id,
"value" => %{
"type" => pt.value_type,
"value" => nil,
"_union_type" => Atom.to_string(pt.value_type)
}
}
end)
{:ok, assign(socket, property_types: property_types, initial_properties: initial_properties)}
end
defmodule MvWeb.MemberLive.Form do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<div>
<Layouts.app flash={@flash}>
<.header>
{@title}
{@page_title}
<:subtitle>
{gettext("Use this form to manage member records and their properties.")}
</:subtitle>
</.header>
<.simple_form
for={@form}
id="member-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
@ -71,22 +46,53 @@ defmodule MvWeb.MemberLive.FormComponent do
/>
</.inputs_for>
<:actions>
<.button phx-disable-with={gettext("Saving...")}>{gettext("Save Member")}</.button>
</:actions>
</.simple_form>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Member")}
</.button>
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def update(assigns, socket) do
def mount(params, _session, socket) do
{:ok, property_types} = Mv.Membership.list_property_types()
initial_properties =
Enum.map(property_types, fn pt ->
%{
"property_type_id" => pt.id,
"value" => %{
"type" => pt.value_type,
"value" => nil,
"_union_type" => Atom.to_string(pt.value_type)
}
}
end)
member =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Member, id)
end
action = if is_nil(member), do: "New", else: "Edit"
page_title = action <> " " <> "Member"
{:ok,
socket
|> assign(assigns)
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:property_types, property_types)
|> assign(:initial_properties, initial_properties)
|> assign(member: member)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"member" => member_params}, socket) do
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))}
@ -107,7 +113,7 @@ defmodule MvWeb.MemberLive.FormComponent do
socket =
socket
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|> push_patch(to: socket.assigns.patch)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
@ -175,4 +181,7 @@ defmodule MvWeb.MemberLive.FormComponent do
assign(socket, form: to_form(form))
end
defp return_path("index", _member), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}"
end

View file

@ -0,0 +1,65 @@
defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{gettext("Listing Members")}
<:actions>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
</:actions>
</.header>
<.table
id="members"
rows={@streams.members}
row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end}
>
<!-- <:col :let={{_id, member}} label="Id">{member.id}</:col> -->
<:col :let={{_id, member}} label={gettext("First Name")}>{member.first_name}</:col>
<:col :let={{_id, member}} label={gettext("Last Name")}>{member.last_name}</:col>
<:col :let={{_id, member}} label={gettext("Email")}>{member.email}</:col>
<:col :let={{_id, member}} label={gettext("City")}>{member.city}</:col>
<:col :let={{_id, member}} label={gettext("Join Date")}>{member.join_date}</:col>
<:action :let={{_id, member}}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
</:action>
<:action :let={{id, member}}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Listing Members"))
|> stream(:members, Ash.read!(Mv.Membership.Member))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
{:noreply, stream_delete(socket, :members, member)}
end
end

View file

@ -0,0 +1,82 @@
defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view
import Ash.Query
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/members"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
</.button>
</:actions>
</.header>
<.list>
<:item title={gettext("Id")}>{@member.id}</:item>
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
<:item title={gettext("Join Date")}>{@member.join_date}</:item>
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item>
<:item title={gettext("Notes")}>{@member.notes}</:item>
<:item title={gettext("City")}>{@member.city}</:item>
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.generic_list items={
Enum.map(@member.properties, fn p ->
{
# name
p.property_type && p.property_type.name,
# value
case p.value do
%{value: v} -> v
v -> v
end
}
end)
} />
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load(properties: [:property_type])
member = Ash.read_one!(query)
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, member)}
end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
end

View file

@ -0,0 +1,94 @@
defmodule MvWeb.PropertyLive.Form do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
</.header>
<.form for={@form} id="property-form" phx-change="validate" phx-submit="save">
<.input field={@form[:value]} type="text" label={gettext("Value")} />
<.input field={@form[:member_id]} type="text" label={gettext("Member")} />
<.input field={@form[:property_type_id]} type="text" label={gettext("Property type")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Property")}
</.button>
<.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
property =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Property, id)
end
action = if is_nil(property), do: "New", else: "Edit"
page_title = action <> " " <> "Property"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(property: property)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"property" => property_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_params))}
end
def handle_event("save", %{"property" => property_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_params) do
{:ok, property} ->
notify_parent({:saved, property})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Property %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property: property}} = socket) do
form =
if property do
AshPhoenix.Form.for_update(property, :update, as: "property")
else
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _property), do: ~p"/properties"
defp return_path("show", property), do: ~p"/properties/#{property.id}"
end

View file

@ -0,0 +1,60 @@
defmodule MvWeb.PropertyLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Properties
<:actions>
<.button variant="primary" navigate={~p"/properties/new"}>
<.icon name="hero-plus" /> New Property
</.button>
</:actions>
</.header>
<.table
id="properties"
rows={@streams.properties}
row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end}
>
<:col :let={{_id, property}} label="Id">{property.id}</:col>
<:action :let={{_id, property}}>
<div class="sr-only">
<.link navigate={~p"/properties/#{property}"}>Show</.link>
</div>
<.link navigate={~p"/properties/#{property}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property}}>
<.link
phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Properties")
|> stream(:properties, Ash.read!(Mv.Membership.Property))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property = Ash.get!(Mv.Membership.Property, id)
Ash.destroy!(property)
{:noreply, stream_delete(socket, :properties, property)}
end
end

View file

@ -0,0 +1,44 @@
defmodule MvWeb.PropertyLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/properties"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit Property
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:property, Ash.get!(Mv.Membership.Property, id))}
end
defp page_title(:show), do: "Show Property"
defp page_title(:edit), do: "Edit Property"
end

View file

@ -0,0 +1,105 @@
defmodule MvWeb.PropertyTypeLive.Form do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage property_type records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Property type")}
</.button>
<.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
property_type =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.PropertyType, id)
end
action = if is_nil(property_type), do: "New", else: "Edit"
page_title = action <> " " <> "Property type"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(property_type: property_type)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"property_type" => property_type_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))}
end
def handle_event("save", %{"property_type" => property_type_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
{:ok, property_type} ->
notify_parent({:saved, property_type})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Property type %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property_type))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property_type: property_type}} = socket) do
form =
if property_type do
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
else
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _property_type), do: ~p"/property_types"
defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}"
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.PropertyTypeLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Property types
<:actions>
<.button variant="primary" navigate={~p"/property_types/new"}>
<.icon name="hero-plus" /> New Property type
</.button>
</:actions>
</.header>
<.table
id="property_types"
rows={@streams.property_types}
row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end}
>
<:col :let={{_id, property_type}} label="Id">{property_type.id}</:col>
<:col :let={{_id, property_type}} label="Name">{property_type.name}</:col>
<:col :let={{_id, property_type}} label="Description">{property_type.description}</:col>
<:action :let={{_id, property_type}}>
<div class="sr-only">
<.link navigate={~p"/property_types/#{property_type}"}>Show</.link>
</div>
<.link navigate={~p"/property_types/#{property_type}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property_type}}>
<.link
phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Property types")
|> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property_type = Ash.get!(Mv.Membership.PropertyType, id)
Ash.destroy!(property_type)
{:noreply, stream_delete(socket, :property_types, property_type)}
end
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.PropertyTypeLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/property_types"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/property_types/#{@property_type}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Property type
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property_type.id}</:item>
<:item title="Name">{@property_type.name}</:item>
<:item title="Description">{@property_type.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Property type")
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))}
end
end

View file

@ -1,104 +0,0 @@
defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
{gettext("Listing Members")}
<:actions>
<.link patch={~p"/members/new"}>
<.button>{gettext("New Member")}</.button>
</.link>
</:actions>
</.header>
<.table
id="members"
rows={@streams.members}
row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end}
>
<!-- <:col :let={{_id, member}} label="Id">{member.id}</:col> -->
<:col :let={{_id, member}} label={gettext("First Name")}>{member.first_name}</:col>
<:col :let={{_id, member}} label={gettext("Last Name")}>{member.last_name}</:col>
<:col :let={{_id, member}} label={gettext("Email")}>{member.email}</:col>
<:col :let={{_id, member}} label={gettext("City")}>{member.city}</:col>
<:col :let={{_id, member}} label={gettext("Join Date")}>{member.join_date}</:col>
<:action :let={{_id, member}}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link patch={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
</:action>
<:action :let={{id, member}}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
</:action>
</.table>
<.modal
:if={@live_action in [:new, :edit]}
id="member-modal"
show
on_cancel={JS.patch(~p"/members")}
>
<.live_component
module={MvWeb.MemberLive.FormComponent}
id={(@member && @member.id) || :new}
title={@page_title}
action={@live_action}
member={@member}
patch={~p"/members"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :members, Ash.read!(Mv.Membership.Member))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit Member"))
|> assign(:member, Ash.get!(Mv.Membership.Member, id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New Member"))
|> assign(:member, nil)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, gettext("Listing Members"))
|> assign(:member, nil)
end
@impl true
def handle_info({MvWeb.MemberLive.FormComponent, {:saved, member}}, socket) do
{:noreply, stream_insert(socket, :members, member)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
{:noreply, stream_delete(socket, :members, member)}
end
end

View file

@ -1,94 +0,0 @@
defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view
import Ash.Query
@impl true
def render(assigns) do
~H"""
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<:actions>
<.link patch={~p"/members/#{@member}/show/edit"} phx-click={JS.push_focus()}>
<.button>{gettext("Edit member")}</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title={gettext("Id")}>{@member.id}</:item>
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
<:item title={gettext("Join Date")}>{@member.join_date}</:item>
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item>
<:item title={gettext("Notes")}>{@member.notes}</:item>
<:item title={gettext("City")}>{@member.city}</:item>
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.generic_list items={
Enum.map(@member.properties, fn p ->
{
# name
p.property_type && p.property_type.name,
# value
case p.value do
%{value: v} -> v
v -> v
end
}
end)
} />
<.back navigate={~p"/members"}>{gettext("Back to members")}</.back>
<.modal
:if={@live_action == :edit}
id="member-modal"
show
on_cancel={JS.patch(~p"/members/#{@member}")}
>
<.live_component
module={MvWeb.MemberLive.FormComponent}
id={@member.id}
title={@page_title}
action={@live_action}
member={@member}
patch={~p"/members/#{@member}"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load(properties: [:property_type])
member = Ash.read_one!(query)
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, member)}
end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
end

View file

@ -1,77 +0,0 @@
defmodule MvWeb.PropertyLive.FormComponent do
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div>
<.header>
{@title}
<:subtitle>Use this form to manage property records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="property-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:value]} type="text" label="Value" /><.input
field={@form[:member_id]}
type="text"
label="Member"
/><.input field={@form[:property_type_id]} type="text" label="Property type" />
<:actions>
<.button phx-disable-with="Saving...">Save Property</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"property" => property_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_params))}
end
def handle_event("save", %{"property" => property_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_params) do
{:ok, property} ->
notify_parent({:saved, property})
socket =
socket
|> put_flash(:info, "Property #{socket.assigns.form.source.type}d successfully")
|> push_patch(to: socket.assigns.patch)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property: property}} = socket) do
form =
if property do
AshPhoenix.Form.for_update(property, :update, as: "property")
else
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
end
assign(socket, form: to_form(form))
end
end

View file

@ -1,99 +0,0 @@
defmodule MvWeb.PropertyLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
Listing Properties
<:actions>
<.link patch={~p"/properties/new"}>
<.button>New Property</.button>
</.link>
</:actions>
</.header>
<.table
id="properties"
rows={@streams.properties}
row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end}
>
<:col :let={{_id, property}} label="Id">{property.id}</:col>
<:action :let={{_id, property}}>
<div class="sr-only">
<.link navigate={~p"/properties/#{property}"}>Show</.link>
</div>
<.link patch={~p"/properties/#{property}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property}}>
<.link
phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal
:if={@live_action in [:new, :edit]}
id="property-modal"
show
on_cancel={JS.patch(~p"/properties")}
>
<.live_component
module={MvWeb.PropertyLive.FormComponent}
id={(@property && @property.id) || :new}
title={@page_title}
action={@live_action}
property={@property}
patch={~p"/properties"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :properties, Ash.read!(Mv.Membership.Property))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Property")
|> assign(:property, Ash.get!(Mv.Membership.Property, id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Property")
|> assign(:property, nil)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Properties")
|> assign(:property, nil)
end
@impl true
def handle_info({MvWeb.PropertyLive.FormComponent, {:saved, property}}, socket) do
{:noreply, stream_insert(socket, :properties, property)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property = Ash.get!(Mv.Membership.Property, id)
Ash.destroy!(property)
{:noreply, stream_delete(socket, :properties, property)}
end
end

View file

@ -1,57 +0,0 @@
defmodule MvWeb.PropertyLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.</:subtitle>
<:actions>
<.link patch={~p"/properties/#{@property}/show/edit"} phx-click={JS.push_focus()}>
<.button>Edit property</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Id">{@property.id}</:item>
</.list>
<.back navigate={~p"/properties"}>Back to properties</.back>
<.modal
:if={@live_action == :edit}
id="property-modal"
show
on_cancel={JS.patch(~p"/properties/#{@property}")}
>
<.live_component
module={MvWeb.PropertyLive.FormComponent}
id={@property.id}
title={@page_title}
action={@live_action}
property={@property}
patch={~p"/properties/#{@property}"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:property, Ash.get!(Mv.Membership.Property, id))}
end
defp page_title(:show), do: "Show Property"
defp page_title(:edit), do: "Edit Property"
end

View file

@ -1,81 +0,0 @@
defmodule MvWeb.PropertyTypeLive.FormComponent do
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div>
<.header>
{@title}
<:subtitle>Use this form to manage property_type records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="property_type-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label="Name" /><.input
field={@form[:type]}
type="text"
label="Type"
/><.input field={@form[:description]} type="text" label="Description" /><.input
field={@form[:immutable]}
type="checkbox"
label="Immutable"
/><.input field={@form[:required]} type="checkbox" label="Required" />
<:actions>
<.button phx-disable-with="Saving...">Save Property type</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"property_type" => property_type_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))}
end
def handle_event("save", %{"property_type" => property_type_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
{:ok, property_type} ->
notify_parent({:saved, property_type})
socket =
socket
|> put_flash(:info, "Property type #{socket.assigns.form.source.type}d successfully")
|> push_patch(to: socket.assigns.patch)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property_type: property_type}} = socket) do
form =
if property_type do
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
else
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
end
assign(socket, form: to_form(form))
end
end

View file

@ -1,103 +0,0 @@
defmodule MvWeb.PropertyTypeLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
Listing Property types
<:actions>
<.link patch={~p"/property_types/new"}>
<.button>New Property type</.button>
</.link>
</:actions>
</.header>
<.table
id="property_types"
rows={@streams.property_types}
row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end}
>
<:col :let={{_id, property_type}} label="Id">{property_type.id}</:col>
<:col :let={{_id, property_type}} label="Name">{property_type.name}</:col>
<:col :let={{_id, property_type}} label="Description">{property_type.description}</:col>
<:action :let={{_id, property_type}}>
<div class="sr-only">
<.link navigate={~p"/property_types/#{property_type}"}>Show</.link>
</div>
<.link patch={~p"/property_types/#{property_type}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property_type}}>
<.link
phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal
:if={@live_action in [:new, :edit]}
id="property_type-modal"
show
on_cancel={JS.patch(~p"/property_types")}
>
<.live_component
module={MvWeb.PropertyTypeLive.FormComponent}
id={(@property_type && @property_type.id) || :new}
title={@page_title}
action={@live_action}
property_type={@property_type}
patch={~p"/property_types"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :property_types, Ash.read!(Mv.Membership.PropertyType))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Property type")
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Property type")
|> assign(:property_type, nil)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Property types")
|> assign(:property_type, nil)
end
@impl true
def handle_info({MvWeb.PropertyTypeLive.FormComponent, {:saved, property_type}}, socket) do
{:noreply, stream_insert(socket, :property_types, property_type)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property_type = Ash.get!(Mv.Membership.PropertyType, id)
Ash.destroy!(property_type)
{:noreply, stream_delete(socket, :property_types, property_type)}
end
end

View file

@ -1,61 +0,0 @@
defmodule MvWeb.PropertyTypeLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.</:subtitle>
<:actions>
<.link patch={~p"/property_types/#{@property_type}/show/edit"} phx-click={JS.push_focus()}>
<.button>Edit property_type</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Id">{@property_type.id}</:item>
<:item title="Name">{@property_type.name}</:item>
<:item title="Description">{@property_type.description}</:item>
</.list>
<.back navigate={~p"/property_types"}>Back to property_types</.back>
<.modal
:if={@live_action == :edit}
id="property_type-modal"
show
on_cancel={JS.patch(~p"/property_types/#{@property_type}")}
>
<.live_component
module={MvWeb.PropertyTypeLive.FormComponent}
id={@property_type.id}
title={@page_title}
action={@live_action}
property_type={@property_type}
patch={~p"/property_types/#{@property_type}"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))}
end
defp page_title(:show), do: "Show Property type"
defp page_title(:edit), do: "Edit Property type"
end

View file

@ -50,20 +50,20 @@ defmodule MvWeb.Router do
get "/", PageController, :home
live "/members", MemberLive.Index, :index
live "/members/new", MemberLive.Index, :new
live "/members/:id/edit", MemberLive.Index, :edit
live "/members/new", MemberLive.Form, :new
live "/members/:id/edit", MemberLive.Form, :edit
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/property_types", PropertyTypeLive.Index, :index
live "/property_types/new", PropertyTypeLive.Index, :new
live "/property_types/:id/edit", PropertyTypeLive.Index, :edit
live "/property_types/new", PropertyTypeLive.Form, :new
live "/property_types/:id/edit", PropertyTypeLive.Form, :edit
live "/property_types/:id", PropertyTypeLive.Show, :show
live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit
live "/properties", PropertyLive.Index, :index
live "/properties/new", PropertyLive.Index, :new
live "/properties/:id/edit", PropertyLive.Index, :edit
live "/properties/new", PropertyLive.Form, :new
live "/properties/:id/edit", PropertyLive.Form, :edit
live "/properties/:id", PropertyLive.Show, :show
live "/properties/:id/show/edit", PropertyLive.Show, :edit

21
mix.exs
View file

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

View file

@ -16,7 +16,7 @@
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
@ -30,7 +30,7 @@
"floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"},
"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"},
"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"},
"igniter": {:hex, :igniter, "0.6.7", "4e183afc59d89289e223c4282fd3e9bb39b82e28d0aa6d3369f70fbd3e21a243", [: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", "43b0a584dc84fd1320772c87047355b604ed2bcdd25392b17f7da8bdd09b61ac"},
"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_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"},
"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.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [: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", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"},
"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"},
@ -57,7 +57,7 @@
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [: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", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
"reactor": {:hex, :reactor, "0.15.4", "ef0c56a901c132529a14ab59fed0ccb4fcecb24308fb189a94c908255d4fdafc", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "783bf62fd0c72ded033afabdb8b6190b7048769771a2a97256e6f0bf4fb0a891"},
@ -70,8 +70,8 @@
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"},
"tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},