├── test
├── test_helper.exs
├── live_view_patterns_web
│ └── controllers
│ │ ├── page_controller_test.exs
│ │ ├── error_json_test.exs
│ │ └── error_html_test.exs
└── support
│ └── conn_case.ex
├── rel
└── overlays
│ └── bin
│ ├── server.bat
│ └── server
├── priv
└── static
│ ├── favicon.ico
│ └── robots.txt
├── .formatter.exs
├── lib
├── live_view_patterns_web
│ ├── remote_data.ex
│ ├── components
│ │ ├── layouts
│ │ │ ├── root.html.heex
│ │ │ ├── app.html.heex
│ │ │ └── sidebar.html.heex
│ │ ├── layouts.ex
│ │ └── core_components.ex
│ ├── router.ex
│ ├── live
│ │ ├── data_loading
│ │ │ ├── async_requests_live
│ │ │ │ └── user_component.ex
│ │ │ ├── partial_async_requests_live.ex
│ │ │ ├── partial_async_requests_live
│ │ │ │ └── user_component.ex
│ │ │ ├── partial_async_requests_live.html.heex
│ │ │ ├── async_requests_live.ex
│ │ │ └── async_requests_live.html.heex
│ │ └── home_live.ex
│ ├── endpoint.ex
│ └── telemetry.ex
├── live_view_patterns
│ ├── repo.ex
│ ├── task_supervisor.ex
│ ├── stats.ex
│ ├── users.ex
│ ├── schemas
│ │ ├── user
│ │ │ └── stats.ex
│ │ └── user.ex
│ ├── repo
│ │ └── seeder.ex
│ └── application.ex
├── live_view_patterns.ex
└── live_view_patterns_web.ex
├── assets
├── css
│ └── app.css
├── js
│ └── app.js
└── tailwind.config.js
├── config
├── test.exs
├── prod.exs
├── config.exs
├── dev.exs
└── runtime.exs
├── README.md
├── fly.toml
├── .gitignore
├── .dockerignore
├── mix.exs
├── Dockerfile
└── mix.lock
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\live_view_patterns" start
3 |
--------------------------------------------------------------------------------
/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bigardone/live_view_patterns/HEAD/priv/static/favicon.ico
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | PHX_SERVER=true exec ./live_view_patterns start
4 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | plugins: [Phoenix.LiveView.HTMLFormatter],
4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
5 | ]
6 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/remote_data.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.RemoteData do
2 | import ExUnion
3 |
4 | defunion(not_requested | requesting(ref) | success(data) | error(reason))
5 | end
6 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Repo do
2 | use Ecto.Repo, otp_app: :live_view_patterns, adapter: Etso.Adapter
3 |
4 | use Scrivener, page_size: 10, max_page_size: 100
5 | end
6 |
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/task_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.TaskSupervisor do
2 | @spec async(fun()) :: reference()
3 | def async(fun) do
4 | __MODULE__
5 | |> Task.Supervisor.async(fun)
6 | |> Map.get(:ref)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&family=Source+Code+Pro:wght@200&display=swap');
2 |
3 | @import "tailwindcss/base";
4 | @import "tailwindcss/components";
5 | @import "tailwindcss/utilities";
6 |
7 | /* This file is for your main application CSS */
8 |
--------------------------------------------------------------------------------
/test/live_view_patterns_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.PageControllerTest do
2 | use LiveViewPatternsWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, ~p"/")
6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/stats.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Stats do
2 | import Ecto.Query
3 |
4 | alias Ecto.UUID
5 |
6 | alias LiveViewPatterns.{
7 | Repo,
8 | Schemas.User.Stats
9 | }
10 |
11 | @spec by_user_ids([UUID.t()]) :: [Stats.t()]
12 | def by_user_ids(user_ids) do
13 | Stats
14 | |> where([s], s.user_id in ^user_ids)
15 | |> Repo.all()
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/live_view_patterns_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.ErrorJSONTest do
2 | use LiveViewPatternsWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert LiveViewPatternsWeb.ErrorJSON.render("404.json", %{}) == %{
6 | errors: %{detail: "Not Found"}
7 | }
8 | end
9 |
10 | test "renders 500" do
11 | assert LiveViewPatternsWeb.ErrorJSON.render("500.json", %{}) ==
12 | %{errors: %{detail: "Internal Server Error"}}
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/users.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Users do
2 | import Ecto.Query
3 |
4 | alias LiveViewPatterns.{
5 | Repo,
6 | Schemas.User
7 | }
8 |
9 | @spec all(with_stats: boolean()) :: [User.t()]
10 | def all(opts \\ []) do
11 | with_stats = Keyword.get(opts, :with_stats, false)
12 |
13 | from(u in User)
14 | |> preload_stats(with_stats)
15 | |> Repo.all()
16 | end
17 |
18 | defp preload_stats(q, false), do: q
19 | defp preload_stats(q, true), do: preload(q, [:stats])
20 | end
21 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "wfhdibXT6nHp0i6M2a4AW/IvRCA5y1zYcvERf3Kb6cMw+3EDVwUU/hIq/TTxW5jr",
8 | server: false
9 |
10 | # Print only warnings and errors during test
11 | config :logger, level: :warning
12 |
13 | # Initialize plugs at runtime for faster test compilation
14 | config :phoenix, :plug_init_mode, :runtime
15 |
--------------------------------------------------------------------------------
/test/live_view_patterns_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.ErrorHTMLTest do
2 | use LiveViewPatternsWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(LiveViewPatternsWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(LiveViewPatternsWeb.ErrorHTML, "500", "html", []) ==
13 | "Internal Server Error"
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/schemas/user/stats.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Schemas.User.Stats do
2 | use LiveViewPatterns, :schema
3 |
4 | alias __MODULE__
5 | alias LiveViewPatterns.Schemas.User
6 |
7 | schema "stats" do
8 | field(:finished_tasks, :integer)
9 | field(:tracked_time, :integer)
10 |
11 | belongs_to(:user, User)
12 | end
13 |
14 | @fields ~w(
15 | user_id
16 | finished_tasks
17 | tracked_time
18 | )a
19 |
20 | def changeset(stats \\ %Stats{}, params) do
21 | stats
22 | |> cast(params, @fields)
23 | |> validate_required(@fields)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · LiveViewPatters">
8 | <%= assigns[:page_title] %>
9 |
10 |
11 |
12 |
13 |
14 | <%= @inner_content %>
15 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <.flash kind={:info} title="Success!" flash={@flash} />
3 | <.flash kind={:error} title="Error!" flash={@flash} />
4 | <.flash
5 | id="disconnected"
6 | kind={:error}
7 | title="We can't find the internet"
8 | close={false}
9 | autoshow={false}
10 | phx-disconnected={show("#disconnected")}
11 | phx-connected={hide("#disconnected")}
12 | >
13 | Attempting to reconnect
14 |
15 |
16 | <%= @inner_content %>
17 |
18 |
19 | <.footer />
20 |
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LiveViewPatterns
2 |
3 | To start your Phoenix server:
4 |
5 | * Install dependencies with `mix deps.get`
6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
7 |
8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
9 |
10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
11 |
12 | ## Learn more
13 |
14 | * Official website: https://www.phoenixframework.org/
15 | * Guides: https://hexdocs.pm/phoenix/overview.html
16 | * Docs: https://hexdocs.pm/phoenix
17 | * Forum: https://elixirforum.com/c/phoenix-forum
18 | * Source: https://github.com/phoenixframework/phoenix
19 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.Router do
2 | use LiveViewPatternsWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, {LiveViewPatternsWeb.Layouts, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | scope "/", LiveViewPatternsWeb do
14 | pipe_through :browser
15 |
16 | live_session :default do
17 | live "/", HomeLive
18 |
19 | scope "/data-loading", DataLoading do
20 | live "/async-requests", AsyncRequestsLive
21 | live "/partial-async-requests", PartialAsyncRequestsLive
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/schemas/user.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Schemas.User do
2 | use LiveViewPatterns, :schema
3 |
4 | alias __MODULE__
5 | alias __MODULE__.Stats
6 |
7 | schema "users" do
8 | field(:first_name, :string)
9 | field(:last_name, :string)
10 | field(:email, :string)
11 | field(:avatar_url, :string)
12 |
13 | has_one(:stats, Stats)
14 | end
15 |
16 | @fields ~w(
17 | first_name
18 | last_name
19 | email
20 | avatar_url
21 | )a
22 |
23 | def changeset(person \\ %User{}, params) do
24 | person
25 | |> cast(params, @fields)
26 | |> validate_required(@fields)
27 | end
28 |
29 | def full_name(%User{first_name: first_name, last_name: last_name}) do
30 | Enum.join([first_name, last_name], " ")
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 |
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
13 | cache_static_manifest: "priv/static/cache_manifest.json"
14 |
15 | # Do not print debug messages in production
16 | config :logger, level: :info
17 |
18 | # Runtime production configuration, including reading
19 | # of environment variables, is done on config/runtime.exs.
20 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
2 | import 'phoenix_html';
3 | // Establish Phoenix Socket and LiveView configuration.
4 | import { Socket } from 'phoenix';
5 | import { LiveSocket } from 'phoenix_live_view';
6 |
7 | const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content');
8 | const liveSocket = new LiveSocket('/live', Socket, { params: { _csrf_token: csrfToken } });
9 |
10 | // connect if there are any LiveViews on the page
11 | liveSocket.connect();
12 |
13 | // expose liveSocket on window for web console debug logs and latency simulation:
14 | // >> liveSocket.enableDebug()
15 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
16 | // >> liveSocket.disableLatencySim()
17 | window.liveSocket = liveSocket;
18 |
--------------------------------------------------------------------------------
/lib/live_view_patterns.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns do
2 | @moduledoc """
3 | LiveViewPatterns keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 |
10 | def schema do
11 | quote do
12 | use Ecto.Schema
13 | import Ecto.Changeset
14 | alias __MODULE__
15 | end
16 | end
17 |
18 | @doc """
19 | When used, dispatch to the appropriate controller/view/etc.
20 | """
21 | defmacro __using__(which) when is_atom(which) do
22 | apply(__MODULE__, which, [])
23 | end
24 |
25 | defdelegate all_users(opts \\ []), to: LiveViewPatterns.Users, as: :all
26 |
27 | defdelegate stats_by_user_ids(user_ids), to: LiveViewPatterns.Stats, as: :by_user_ids
28 | end
29 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for live-view-patterns on 2022-12-04T16:55:55+01:00
2 |
3 | app = "live-view-patterns"
4 | kill_signal = "SIGTERM"
5 | kill_timeout = 5
6 | processes = []
7 |
8 | [env]
9 | PHX_HOST = "live-view-patterns.fly.dev"
10 | PORT = "8080"
11 |
12 | [experimental]
13 | allowed_public_ports = []
14 | auto_rollback = true
15 |
16 | [[services]]
17 | http_checks = []
18 | internal_port = 8080
19 | processes = ["app"]
20 | protocol = "tcp"
21 | script_checks = []
22 | [services.concurrency]
23 | hard_limit = 25
24 | soft_limit = 20
25 | type = "connections"
26 |
27 | [[services.ports]]
28 | force_https = true
29 | handlers = ["http"]
30 | port = 80
31 |
32 | [[services.ports]]
33 | handlers = ["tls", "http"]
34 | port = 443
35 |
36 | [[services.tcp_checks]]
37 | grace_period = "1s"
38 | interval = "15s"
39 | restart_limit = 0
40 | timeout = "2s"
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | live_view_patterns-*.tar
24 |
25 | # Ignore assets that are produced by build tools.
26 | /priv/static/assets/
27 |
28 | # Ignore digested assets cache.
29 | /priv/static/cache_manifest.json
30 |
31 | # In case you use Node.js/npm, you want to ignore these.
32 | npm-debug.log
33 | /assets/node_modules/
34 |
35 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # flyctl launch added from .elixir_ls/.gitignore
2 | .elixir_ls/**/*
3 |
4 | # flyctl launch added from .gitignore
5 | # The directory Mix will write compiled artifacts to.
6 | _build
7 |
8 | # If you run "mix test --cover", coverage assets end up here.
9 | cover
10 |
11 | # The directory Mix downloads your dependencies sources to.
12 | deps
13 |
14 | # Where 3rd-party dependencies like ExDoc output generated docs.
15 | doc
16 |
17 | # Ignore .fetch files in case you like to edit your project deps locally.
18 | .fetch
19 |
20 | # If the VM crashes, it generates a dump, let's ignore it too.
21 | **/erl_crash.dump
22 |
23 | # Also ignore archive artifacts (built via "mix archive.build").
24 | **/*.ez
25 |
26 | # Ignore package tarball (built via "mix hex.build").
27 | **/live_view_patterns-*.tar
28 |
29 | # Ignore assets that are produced by build tools.
30 | priv/static/assets
31 |
32 | # Ignore digested assets cache.
33 | priv/static/cache_manifest.json
34 |
35 | # In case you use Node.js/npm, you want to ignore these.
36 | **/npm-debug.log
37 | assets/node_modules
38 |
39 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require('tailwindcss/plugin');
5 |
6 | module.exports = {
7 | content: [
8 | './js/**/*.js',
9 | '../lib/*_web.ex',
10 | '../lib/*_web/**/*.*ex',
11 | ],
12 | theme: {
13 | fontFamily: {
14 | sans: ['Poppins', 'Arial', 'sans-serif'],
15 | mono: ['"Source Code Pro"', 'ui-monospace', 'SFMono-Regular'],
16 | },
17 | maxWidth: {
18 | '8xl': '90rem',
19 | },
20 | },
21 | plugins: [
22 | require('@tailwindcss/forms'),
23 | plugin(({ addVariant }) => addVariant('phx-no-feedback', ['.phx-no-feedback&', '.phx-no-feedback &'])),
24 | plugin(({ addVariant }) => addVariant('phx-click-loading', ['.phx-click-loading&', '.phx-click-loading &'])),
25 | plugin(({ addVariant }) => addVariant('phx-submit-loading', ['.phx-submit-loading&', '.phx-submit-loading &'])),
26 | plugin(({ addVariant }) => addVariant('phx-change-loading', ['.phx-change-loading&', '.phx-change-loading &'])),
27 | ],
28 | };
29 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use LiveViewPatternsWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint LiveViewPatternsWeb.Endpoint
24 |
25 | use LiveViewPatternsWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import LiveViewPatternsWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/repo/seeder.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Repo.Seeder do
2 | alias LiveViewPatterns.{
3 | Repo,
4 | Schemas.User,
5 | Schemas.User.Stats
6 | }
7 |
8 | def child_spec(opts) do
9 | %{
10 | id: __MODULE__,
11 | start: {__MODULE__, :start_link, [opts]},
12 | type: :worker,
13 | restart: :permanent,
14 | shutdown: 500
15 | }
16 | end
17 |
18 | def start_link(_opts) do
19 | run()
20 |
21 | :ignore
22 | end
23 |
24 | defp run do
25 | for _ <- 1..15 do
26 | first_name = Faker.Person.first_name()
27 | last_name = Faker.Person.last_name()
28 |
29 | email =
30 | [first_name, last_name]
31 | |> Enum.join(".")
32 | |> String.downcase()
33 | |> then(&(&1 <> "@" <> Faker.Internet.free_email_service()))
34 |
35 | user =
36 | %{
37 | first_name: first_name,
38 | last_name: last_name,
39 | email: email,
40 | avatar_url: Faker.Avatar.image_url()
41 | }
42 | |> User.changeset()
43 | |> Repo.insert!()
44 |
45 | for _ <- 5..15 do
46 | %{
47 | user_id: user.id,
48 | finished_tasks: Enum.random(5..20),
49 | tracked_time: Enum.random(60..6_000)
50 | }
51 | |> Stats.changeset()
52 | |> Repo.insert!()
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/live_view_patterns/application.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | # Start the Telemetry supervisor
12 | LiveViewPatternsWeb.Telemetry,
13 | # Start the PubSub system
14 | {Phoenix.PubSub, name: LiveViewPatterns.PubSub},
15 | # Start the task supervisor
16 | {Task.Supervisor, name: LiveViewPatterns.TaskSupervisor},
17 | # Start the Repo
18 | LiveViewPatterns.Repo,
19 | LiveViewPatterns.Repo.Seeder,
20 | # Start the Endpoint (http/https)
21 | LiveViewPatternsWeb.Endpoint
22 | # Start a worker by calling: LiveViewPatterns.Worker.start_link(arg)
23 | # {LiveViewPatterns.Worker, arg}
24 | ]
25 |
26 | # See https://hexdocs.pm/elixir/Supervisor.html
27 | # for other strategies and supported options
28 | opts = [strategy: :one_for_one, name: LiveViewPatterns.Supervisor]
29 | Supervisor.start_link(children, opts)
30 | end
31 |
32 | # Tell Phoenix to update the endpoint configuration
33 | # whenever the application is updated.
34 | @impl true
35 | def config_change(changed, _new, removed) do
36 | LiveViewPatternsWeb.Endpoint.config_change(changed, removed)
37 | :ok
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/async_requests_live/user_component.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.DataLoading.AsyncRequestsLive.UserComponent do
2 | use Phoenix.Component
3 |
4 | alias LiveViewPatterns.Schemas.User
5 |
6 | def user(assigns) do
7 | ~H"""
8 |
12 |
13 |
14 |
15 |
16 |
<%= User.full_name(@user) %>
17 |
18 | <%= @user.email %>
19 |
20 |
21 |
22 | <.stats text="Num. tasks" value={@user.stats.finished_tasks} />
23 | <.stats text="Time" value={"#{Float.round(@user.stats.tracked_time / 3600, 1)}h"} />
24 |
25 |
26 | """
27 | end
28 |
29 | attr :text, :string, required: true
30 | attr :value, :string, required: true
31 |
32 | defp stats(assigns) do
33 | ~H"""
34 |
35 |
<%= @text %>
36 |
<%= @value %>
37 |
38 | """
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.Layouts do
2 | use LiveViewPatternsWeb, :html
3 |
4 | embed_templates "layouts/*"
5 |
6 | def footer(assigns) do
7 | ~H"""
8 |
9 |
10 | © <%= DateTime.utc_now().year %> LiveView Patterns.
11 | Crafted with ♥
12 | by bigardone .
13 |
14 |
21 |
22 | """
23 | end
24 |
25 | defp navbar(page_name) do
26 | [
27 | %{
28 | title: "Data loading",
29 | sections: [
30 | %{
31 | title: "Async requests",
32 | active: page_name == LiveViewPatternsWeb.DataLoading.AsyncRequestsLive,
33 | path: ~p"/data-loading/async-requests"
34 | },
35 | %{
36 | title: "Partial async requests",
37 | active: page_name == LiveViewPatternsWeb.DataLoading.PartialAsyncRequestsLive,
38 | path: ~p"/data-loading/partial-async-requests"
39 | }
40 | ]
41 | }
42 | ]
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :live_view_patterns
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_live_view_patterns_key",
10 | signing_salt: "ByehGDtS",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
15 |
16 | # Serve at "/" the static files from "priv/static" directory.
17 | #
18 | # You should set gzip to true if you are running phx.digest
19 | # when deploying your static files in production.
20 | plug Plug.Static,
21 | at: "/",
22 | from: :live_view_patterns,
23 | gzip: false,
24 | only: LiveViewPatternsWeb.static_paths()
25 |
26 | # Code reloading can be explicitly enabled under the
27 | # :code_reloader configuration of your endpoint.
28 | if code_reloading? do
29 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
30 | plug Phoenix.LiveReloader
31 | plug Phoenix.CodeReloader
32 | end
33 |
34 | plug Plug.RequestId
35 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
36 |
37 | plug Plug.Parsers,
38 | parsers: [:urlencoded, :multipart, :json],
39 | pass: ["*/*"],
40 | json_decoder: Phoenix.json_library()
41 |
42 | plug Plug.MethodOverride
43 | plug Plug.Head
44 | plug Plug.Session, @session_options
45 | plug LiveViewPatternsWeb.Router
46 | end
47 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/partial_async_requests_live.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.DataLoading.PartialAsyncRequestsLive do
2 | use LiveViewPatternsWeb, :live_view
3 |
4 | import __MODULE__.UserComponent
5 |
6 | @impl LiveView
7 | def mount(_params, _session, socket) do
8 | socket =
9 | socket
10 | |> assign(page_title: "Async requests and partial loading")
11 | |> assign(page_name: __MODULE__)
12 |
13 | {:ok, socket, layout: {LiveViewPatternsWeb.Layouts, :sidebar}}
14 | end
15 |
16 | @impl LiveView
17 | def handle_params(params, _uri, socket) do
18 | force_error = Map.get(params, "force_error", false)
19 | users = LiveViewPatterns.all_users()
20 |
21 | if connected?(socket) do
22 | fetch_users(force_error)
23 | end
24 |
25 | {:noreply, stream(socket, :users, users)}
26 | end
27 |
28 | @impl LiveView
29 | def handle_info({:fetch_users, {:ok, users}}, socket) do
30 | socket =
31 | Enum.reduce(users, socket, fn user, acc ->
32 | stream_insert(acc, :users, user)
33 | end)
34 |
35 | {:noreply, socket}
36 | end
37 |
38 | def handle_info({:fetch_users, {:error, _}}, socket) do
39 | {:noreply, socket}
40 | end
41 |
42 | defp fetch_users(force_error) do
43 | pid = self()
44 |
45 | Task.Supervisor.start_child(LiveViewPatterns.TaskSupervisor, fn ->
46 | Process.sleep(500)
47 |
48 | result =
49 | if force_error do
50 | {:error, :timeout}
51 | else
52 | {:ok, LiveViewPatterns.all_users(with_stats: true)}
53 | end
54 |
55 | send(pid, {:fetch_users, result})
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | # Configures the endpoint
11 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
12 | url: [host: "localhost"],
13 | render_errors: [
14 | formats: [html: LiveViewPatternsWeb.ErrorHTML, json: LiveViewPatternsWeb.ErrorJSON],
15 | layout: false
16 | ],
17 | pubsub_server: LiveViewPatterns.PubSub,
18 | live_view: [signing_salt: "p5XwEee4"]
19 |
20 | # Configure esbuild (the version is required)
21 | config :esbuild,
22 | version: "0.14.41",
23 | default: [
24 | args:
25 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
26 | cd: Path.expand("../assets", __DIR__),
27 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
28 | ]
29 |
30 | # Configure tailwind (the version is required)
31 | config :tailwind,
32 | version: "3.2.4",
33 | default: [
34 | args: ~w(
35 | --config=tailwind.config.js
36 | --input=css/app.css
37 | --output=../priv/static/assets/app.css
38 | ),
39 | cd: Path.expand("../assets", __DIR__)
40 | ]
41 |
42 | # Configures Elixir's Logger
43 | config :logger, :console,
44 | format: "$time $metadata[$level] $message\n",
45 | metadata: [:request_id]
46 |
47 | # Use Jason for JSON parsing in Phoenix
48 | config :phoenix, :json_library, Jason
49 |
50 | # Import environment specific config. This must remain at the bottom
51 | # of this file so it overrides the configuration defined above.
52 | import_config "#{config_env()}.exs"
53 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/partial_async_requests_live/user_component.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.DataLoading.PartialAsyncRequestsLive.UserComponent do
2 | use Phoenix.Component
3 |
4 | alias LiveViewPatterns.Schemas.{User, User.Stats}
5 |
6 | def user(assigns) do
7 | ~H"""
8 |
12 |
13 |
14 |
15 |
16 |
<%= User.full_name(@user) %>
17 |
18 | <%= @user.email %>
19 |
20 |
21 |
22 | <%= case @user.stats do %>
23 | <% %Stats{finished_tasks: finished_tasks, tracked_time: tracked_time} -> %>
24 | <.stats text="Num. tasks" value={finished_tasks} />
25 | <.stats text="Time" value={"#{Float.round(tracked_time / 3600, 1)}h"} />
26 | <% _ -> %>
27 |
31 | <% end %>
32 |
33 |
34 | """
35 | end
36 |
37 | attr :text, :string, required: true
38 | attr :value, :string, required: true
39 |
40 | defp stats(assigns) do
41 | ~H"""
42 |
43 |
<%= @text %>
44 |
<%= @value %>
45 |
46 | """
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/partial_async_requests_live.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Data loading
5 |
8 |
9 | This pattern is particularly useful when you need to retrieve data from multiple sources or perform complex calculations. It initiates by rendering the initial data, while making asynchronous requests for the more intensive part of the process. During this time, a skeleton placeholder is displayed to indicate progress.
10 |
11 |
12 | <.action_button navigate={~p"/data-loading/partial-async-requests"}>
13 | Reload page
14 |
15 | <.action_button navigate={~p"/data-loading/partial-async-requests?force_error=true"}>
16 | Reload page with error
17 |
18 |
19 |
20 |
21 | <.browser_window>
22 |
25 |
26 | <.user :for={{_, user} <- @streams.users} user={user} />
27 |
28 |
29 | <.footer />
30 |
31 |
32 |
33 |
34 |
35 | Implementation
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/async_requests_live.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.DataLoading.AsyncRequestsLive do
2 | use LiveViewPatternsWeb, :live_view
3 |
4 | import __MODULE__.UserComponent
5 |
6 | alias LiveViewPatternsWeb.RemoteData
7 | alias Phoenix.LiveView.JS
8 |
9 | @impl LiveView
10 | def mount(_params, _session, socket) do
11 | socket =
12 | socket
13 | |> assign(page_title: "Async requests, union types, and skeletons")
14 | |> assign(page_name: __MODULE__)
15 | |> assign(force_error: false)
16 |
17 | {:ok, socket, layout: {LiveViewPatternsWeb.Layouts, :sidebar}}
18 | end
19 |
20 | @impl LiveView
21 | def handle_params(_unsigned_params, _uri, socket) do
22 | data =
23 | if connected?(socket) do
24 | ref = fetch_data()
25 | RemoteData.requesting(ref)
26 | else
27 | RemoteData.not_requested()
28 | end
29 |
30 | {:noreply, assign(socket, data: data)}
31 | end
32 |
33 | @impl LiveView
34 | def handle_event("force_error", _params, socket) do
35 | ref = fetch_data(true)
36 | {:noreply, assign(socket, data: RemoteData.requesting(ref))}
37 | end
38 |
39 | @impl LiveView
40 | def handle_info(
41 | {ref, {:ok, data}},
42 | %{assigns: %{data: %RemoteData.Requesting{ref: ref}}} = socket
43 | ) do
44 | Process.demonitor(ref, [:flush])
45 |
46 | {:noreply, assign(socket, data: RemoteData.success(data))}
47 | end
48 |
49 | def handle_info(
50 | {ref, {:error, _}},
51 | %{assigns: %{data: %RemoteData.Requesting{ref: ref}}} = socket
52 | ) do
53 | Process.demonitor(ref, [:flush])
54 |
55 | {:noreply, assign(socket, data: RemoteData.error("Internal error"))}
56 | end
57 |
58 | defp fetch_data(force_error \\ false) do
59 | LiveViewPatterns.TaskSupervisor.async(fn ->
60 | Process.sleep(500)
61 |
62 | if force_error do
63 | {:error, :timeout}
64 | else
65 | {:ok, LiveViewPatterns.all_users(with_stats: true)}
66 | end
67 | end)
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatterns.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :live_view_patterns,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {LiveViewPatterns.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:phoenix, "~> 1.7.0-rc.1", override: true},
36 | {:phoenix_html, "~> 3.0"},
37 | {:phoenix_live_reload, "~> 1.2", only: :dev},
38 | {:phoenix_live_view, "~> 0.18.3"},
39 | {:heroicons, "~> 0.5"},
40 | {:floki, ">= 0.30.0", only: :test},
41 | {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
42 | {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
43 | {:telemetry_metrics, "~> 0.6"},
44 | {:telemetry_poller, "~> 1.0"},
45 | {:jason, "~> 1.2"},
46 | {:plug_cowboy, "~> 2.5"},
47 | {:faker, "~> 0.17.0"},
48 | {:etso, "~> 1.1.0"},
49 | {:scrivener_list, "~> 2.0"},
50 | {:ex_union, "~> 0.1.0"}
51 | ]
52 | end
53 |
54 | # Aliases are shortcuts or tasks specific to the current project.
55 | # For example, to install project dependencies and perform other setup tasks, run:
56 | #
57 | # $ mix setup
58 | #
59 | # See the documentation for `Mix` for more info on aliases.
60 | defp aliases do
61 | [
62 | setup: ["deps.get"],
63 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
64 | ]
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_join.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # VM Metrics
55 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
56 | summary("vm.total_run_queue_lengths.total"),
57 | summary("vm.total_run_queue_lengths.cpu"),
58 | summary("vm.total_run_queue_lengths.io")
59 | ]
60 | end
61 |
62 | defp periodic_measurements do
63 | [
64 | # A module, function and arguments to be invoked periodically.
65 | # This function must call :telemetry.execute/3 and a metric must be added above.
66 | # {LiveViewPatternsWeb, :count_users, []}
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with esbuild to bundle .js and .css sources.
9 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "Wg4cSZgZEsBRbuUkE3y6/9rB2fv9iHo/99pIeTSXnf2VD5cHqA6mO0H1lj6XFeLR",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
19 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
20 | ]
21 |
22 | # ## SSL Support
23 | #
24 | # In order to use HTTPS in development, a self-signed
25 | # certificate can be generated by running the following
26 | # Mix task:
27 | #
28 | # mix phx.gen.cert
29 | #
30 | # Run `mix help phx.gen.cert` for more information.
31 | #
32 | # The `http:` config above can be replaced with:
33 | #
34 | # https: [
35 | # port: 4001,
36 | # cipher_suite: :strong,
37 | # keyfile: "priv/cert/selfsigned_key.pem",
38 | # certfile: "priv/cert/selfsigned.pem"
39 | # ],
40 | #
41 | # If desired, both `http:` and `https:` keys can be
42 | # configured to run both http and https servers on
43 | # different ports.
44 |
45 | # Watch static and templates for browser reloading.
46 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
47 | live_reload: [
48 | patterns: [
49 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
50 | ~r"lib/live_view_patterns_web/(controllers|live|components)/.*(ex|heex)$"
51 | ]
52 | ]
53 |
54 | # Enable dev routes for dashboard and mailbox
55 | config :live_view_patterns, dev_routes: true
56 |
57 | # Do not include metadata nor timestamps in development logs
58 | config :logger, :console, format: "[$level] $message\n"
59 |
60 | # Set a higher stacktrace during development. Avoid configuring such
61 | # in production as building large stacktraces may be expensive.
62 | config :phoenix, :stacktrace_depth, 20
63 |
64 | # Initialize plugs at runtime for faster development compilation
65 | config :phoenix, :plug_init_mode, :runtime
66 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/home_live.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.HomeLive do
2 | use LiveViewPatternsWeb, :live_view
3 |
4 | @impl LiveView
5 | def mount(_params, _session, socket) do
6 | socket = assign(socket, page_title: "Home")
7 |
8 | {:ok, socket}
9 | end
10 |
11 | @impl LiveView
12 | def render(assigns) do
13 | ~H"""
14 |
15 |
16 |
17 | LiveView Patterns
18 |
19 |
20 | Common patterns and practices using Phoenix LiveView.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Latest patterns
29 |
30 |
31 | <.link
32 | navigate={~p"/data-loading/async-requests"}
33 | class="hover:shadow-xl hover:scale-105 transition-all block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow-md"
34 | >
35 |
Data loading
36 |
37 | Async requests
38 |
39 |
40 | A helpful pattern that renders some visual feedback while loading data asynchronously.
41 |
42 |
43 | <.link
44 | navigate={~p"/data-loading/partial-async-requests"}
45 | class="hover:shadow-xl hover:scale-105 transition-all block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow-md"
46 | >
47 |
Data loading
48 |
49 | Partial async requests
50 |
51 |
52 | A helpful pattern that renders some visual feedback while loading data asynchronously.
53 |
54 |
55 |
56 |
57 |
58 | """
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
2 | # instead of Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | # This file is based on these images:
8 | #
9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20221004-slim - for the release image
11 | # - https://pkgs.org/ - resource for finding needed packages
12 | # - Ex: hexpm/elixir:1.14.2-erlang-25.1.2-debian-bullseye-20221004-slim
13 | #
14 | ARG ELIXIR_VERSION=1.14.2
15 | ARG OTP_VERSION=25.1.2
16 | ARG DEBIAN_VERSION=bullseye-20221004-slim
17 |
18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
20 |
21 | FROM ${BUILDER_IMAGE} as builder
22 |
23 | # install build dependencies
24 | RUN apt-get update -y && apt-get install -y build-essential git \
25 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
26 |
27 | # prepare build dir
28 | WORKDIR /app
29 |
30 | # install hex + rebar
31 | RUN mix local.hex --force && \
32 | mix local.rebar --force
33 |
34 | # set build ENV
35 | ENV MIX_ENV="prod"
36 |
37 | # install mix dependencies
38 | COPY mix.exs mix.lock ./
39 | RUN mix deps.get --only $MIX_ENV
40 | RUN mkdir config
41 |
42 | # copy compile-time config files before we compile dependencies
43 | # to ensure any relevant config change will trigger the dependencies
44 | # to be re-compiled.
45 | COPY config/config.exs config/${MIX_ENV}.exs config/
46 | RUN mix deps.compile
47 |
48 | COPY priv priv
49 |
50 | COPY lib lib
51 |
52 | COPY assets assets
53 |
54 | # compile assets
55 | RUN mix assets.deploy
56 |
57 | # Compile the release
58 | RUN mix compile
59 |
60 | # Changes to config/runtime.exs don't require recompiling the code
61 | COPY config/runtime.exs config/
62 |
63 | COPY rel rel
64 | RUN mix release
65 |
66 | # start a new build stage so that the final image will only contain
67 | # the compiled release and other runtime necessities
68 | FROM ${RUNNER_IMAGE}
69 |
70 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
71 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
72 |
73 | # Set the locale
74 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
75 |
76 | ENV LANG en_US.UTF-8
77 | ENV LANGUAGE en_US:en
78 | ENV LC_ALL en_US.UTF-8
79 |
80 | WORKDIR "/app"
81 | RUN chown nobody /app
82 |
83 | # set runner ENV
84 | ENV MIX_ENV="prod"
85 |
86 | # Only copy the final release from the build stage
87 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/live_view_patterns ./
88 |
89 | USER nobody
90 |
91 | CMD ["/app/bin/server"]
92 |
93 | # Appended by flyctl
94 | ENV ECTO_IPV6 true
95 | ENV ERL_AFLAGS "-proto_dist inet6_tcp"
96 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use LiveViewPatternsWeb, :controller
9 | use LiveViewPatternsWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | namespace: LiveViewPatternsWeb,
43 | formats: [:html, :json],
44 | layouts: [html: LiveViewPatternsWeb.Layouts]
45 |
46 | import Plug.Conn
47 |
48 | unquote(verified_routes())
49 | end
50 | end
51 |
52 | def live_view do
53 | quote do
54 | use Phoenix.LiveView,
55 | layout: {LiveViewPatternsWeb.Layouts, :app}
56 |
57 | alias Phoenix.LiveView
58 |
59 | import LiveViewPatternsWeb.Layouts, only: [footer: 1]
60 |
61 | unquote(html_helpers())
62 | end
63 | end
64 |
65 | def live_component do
66 | quote do
67 | use Phoenix.LiveComponent
68 |
69 | unquote(html_helpers())
70 | end
71 | end
72 |
73 | def html do
74 | quote do
75 | use Phoenix.Component
76 |
77 | # Import convenience functions from controllers
78 | import Phoenix.Controller,
79 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
80 |
81 | # Include general helpers for rendering HTML
82 | unquote(html_helpers())
83 | end
84 | end
85 |
86 | defp html_helpers do
87 | quote do
88 | # HTML escaping functionality
89 | import Phoenix.HTML
90 | # Core UI components and translation
91 | import LiveViewPatternsWeb.CoreComponents
92 |
93 | # Shortcut for generating JS commands
94 | alias Phoenix.LiveView.JS
95 |
96 | # Routes generation with the ~p sigil
97 | unquote(verified_routes())
98 | end
99 | end
100 |
101 | def verified_routes do
102 | quote do
103 | use Phoenix.VerifiedRoutes,
104 | endpoint: LiveViewPatternsWeb.Endpoint,
105 | router: LiveViewPatternsWeb.Router,
106 | statics: LiveViewPatternsWeb.static_paths()
107 | end
108 | end
109 |
110 | @doc """
111 | When used, dispatch to the appropriate controller/view/etc.
112 | """
113 | defmacro __using__(which) when is_atom(which) do
114 | apply(__MODULE__, which, [])
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/components/layouts/sidebar.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | <.link navigate={~p"/"} class="font-black leading-none tracking-tight text-purple-700">
4 | LiveView Patterns
5 |
6 |
10 |
11 |
12 | <.link navigate={~p"/"} class="" aria-current="page">
13 | Home
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 | <%= title %>
41 |
42 |
43 | <.navigation_link path={path} active={active}>
44 | <%= title %>
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | <.flash kind={:info} title="Success!" flash={@flash} />
55 | <.flash kind={:error} title="Error!" flash={@flash} />
56 | <.flash
57 | id="disconnected"
58 | kind={:error}
59 | title="We can't find the internet"
60 | close={false}
61 | autoshow={false}
62 | phx-disconnected={show("#disconnected")}
63 | phx-connected={hide("#disconnected")}
64 | >
65 | Attempting to reconnect
66 |
67 |
68 | <%= @inner_content %>
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/live_view_patterns start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | # The secret key base is used to sign/encrypt cookies and other secrets.
25 | # A default value is used in config/dev.exs and config/test.exs but you
26 | # want to use a different value for prod and you most likely don't want
27 | # to check this value into version control, so we use an environment
28 | # variable instead.
29 | secret_key_base =
30 | System.get_env("SECRET_KEY_BASE") ||
31 | raise """
32 | environment variable SECRET_KEY_BASE is missing.
33 | You can generate one by calling: mix phx.gen.secret
34 | """
35 |
36 | host = System.get_env("PHX_HOST") || "example.com"
37 | port = String.to_integer(System.get_env("PORT") || "4000")
38 |
39 | config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
40 | url: [host: host, port: 443, scheme: "https"],
41 | http: [
42 | # Enable IPv6 and bind on all interfaces.
43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
46 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
47 | port: port
48 | ],
49 | secret_key_base: secret_key_base
50 |
51 | # ## SSL Support
52 | #
53 | # To get SSL working, you will need to add the `https` key
54 | # to your endpoint configuration:
55 | #
56 | # config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
57 | # https: [
58 | # ...,
59 | # port: 443,
60 | # cipher_suite: :strong,
61 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
62 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
63 | # ]
64 | #
65 | # The `cipher_suite` is set to `:strong` to support only the
66 | # latest and more secure SSL ciphers. This means old browsers
67 | # and clients may not be supported. You can set it to
68 | # `:compatible` for wider support.
69 | #
70 | # `:keyfile` and `:certfile` expect an absolute path to the key
71 | # and cert in disk or a relative path inside priv, for example
72 | # "priv/ssl/server.key". For all supported SSL configuration
73 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
74 | #
75 | # We also recommend setting `force_ssl` in your endpoint, ensuring
76 | # no data is ever sent via http, always redirecting to https:
77 | #
78 | # config :live_view_patterns, LiveViewPatternsWeb.Endpoint,
79 | # force_ssl: [hsts: true]
80 | #
81 | # Check `Plug.SSL` for all available options in `force_ssl`.
82 | end
83 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/live/data_loading/async_requests_live.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Data loading
5 |
8 |
9 | This pattern is incredibly advantageous when the initial data load is time-consuming, leading to a feeling of sluggishness in the UI.
10 | It leverages union types , asynchronous requests , and
11 | skeleton placeholders
12 | to give the user quick visual feedback, creating a more seamless experience.
13 |
14 |
15 | <.action_button patch={~p"/data-loading/async-requests"}>
16 | Reload page
17 |
18 | <.action_button click={JS.push("force_error")}>
19 | Reload page with error
20 |
21 |
22 |
23 |
24 | <.browser_window>
25 |
28 | <%= case @data do %>
29 | <% %RemoteData.NotRequested{} -> %>
30 | <.list_skeleton />
31 | <% %RemoteData.Requesting{} -> %>
32 | <.list_skeleton />
33 | <% %RemoteData.Error{} -> %>
34 |
35 |
36 |
37 | Oops!
38 |
39 |
An error occurred while fetching the data.
40 |
41 | <% %RemoteData.Success{data: data} -> %>
42 |
43 | <.user :for={user <- data} user={user} />
44 |
45 | <% end %>
46 |
47 | <.footer />
48 |
49 |
50 |
51 |
52 |
53 | Implementation
54 |
55 |
56 |
57 | Create a union type representing the possible states of a remote data request
58 | (<.code>%NotRequested{},
59 | <.code>%Requesting{ref: reference()},
60 | <.code>%Success{data: any()}, and
61 | <.code>%Error{reason: any()})
62 |
67 | View implementation
68 |
69 |
70 |
71 | When the live view mounts, assign the
72 | <.code>requesting
73 | state to the socket and spawn a task requesting the data.
74 |
75 |
76 | The live view handles the message with the task's result and assigns the data to the socket with the corresponding type.
77 |
78 |
79 | The live view's template renders a skeleton placeholder while the task fetches the data or either the data or an error state once it handles the task's result.
80 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/lib/live_view_patterns_web/components/core_components.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewPatternsWeb.CoreComponents do
2 | @moduledoc """
3 | Provides core UI components.
4 |
5 | The components in this module use Tailwind CSS, a utility-first CSS framework.
6 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
7 | customize the generated components in this module.
8 |
9 | Icons are provided by [heroicons](https://heroicons.com), using the
10 | [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
11 | """
12 | use Phoenix.Component
13 |
14 | alias Phoenix.LiveView.JS
15 |
16 | @doc """
17 | Renders flash notices.
18 |
19 | ## Examples
20 |
21 | <.flash kind={:info} flash={@flash} />
22 | <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
23 | """
24 | attr :id, :string, default: "flash", doc: "the optional id of flash container"
25 | attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
26 | attr :title, :string, default: nil
27 | attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
28 | attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
29 | attr :close, :boolean, default: true, doc: "whether the flash can be closed"
30 | attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
31 |
32 | slot :inner_block, doc: "the optional inner block that renders the flash message"
33 |
34 | def flash(assigns) do
35 | ~H"""
36 | hide("#flash")}
41 | role="alert"
42 | class={[
43 | "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
44 | @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
45 | @kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
46 | ]}
47 | {@rest}
48 | >
49 |
50 |
51 |
52 | <%= @title %>
53 |
54 |
<%= msg %>
55 |
56 |
57 |
58 |
59 | """
60 | end
61 |
62 | @doc """
63 | Renders a list skeleton placeholder
64 | """
65 | attr(:lines, :integer, default: 15)
66 |
67 | def list_skeleton(assigns) do
68 | ~H"""
69 |
70 |
88 |
Loading...
89 |
90 | """
91 | end
92 |
93 | @doc """
94 | Renders a navigation link
95 | """
96 | attr(:path, :string, required: true)
97 | attr(:active, :boolean, default: false)
98 |
99 | slot(:inner_block, required: true)
100 |
101 | def navigation_link(assigns) do
102 | ~H"""
103 | <.link
104 | navigate={@path}
105 | class={[
106 | "flex items-center pl-4 text-sm transition-colors",
107 | @active && "text-purple-700 font-semibold border-l border-purple-700",
108 | not @active &&
109 | "hover:text-gray-900 text-gray-600 border-l hover:border-gray-700 border-transparent"
110 | ]}
111 | >
112 | <%= render_slot(@inner_block) %>
113 |
114 | """
115 | end
116 |
117 | attr(:id, :string, required: true)
118 | attr(:title, :string, required: true)
119 | attr(:close_patch, :string, required: true)
120 |
121 | slot(:inner_block, required: true)
122 |
123 | def drawer(assigns) do
124 | ~H"""
125 | <.link
126 | class="bg-opacity-0 transition-colors fixed inset-0 z-30 bg-gray-900"
127 | patch={@close_patch}
128 | phx-mounted={show_backdrop()}
129 | phx-remove={hide_backdrop()}
130 | >
131 |
132 |
157 | """
158 | end
159 |
160 | slot(:inner_block, required: true)
161 |
162 | def code(assigns) do
163 | ~H"""
164 | <%= render_slot(@inner_block) %>
165 | """
166 | end
167 |
168 | attr(:navigate, :string)
169 | attr(:patch, :string)
170 | attr(:click, JS)
171 |
172 | slot(:inner_block, required: true)
173 |
174 | def action_button(%{navigate: _} = assigns) do
175 | ~H"""
176 | <.link navigate={@navigate} class={action_button_class()}>
177 | <%= render_slot(@inner_block) %>
178 |
179 | """
180 | end
181 |
182 | def action_button(%{patch: _} = assigns) do
183 | ~H"""
184 | <.link patch={@patch} class={action_button_class()}>
185 | <%= render_slot(@inner_block) %>
186 |
187 | """
188 | end
189 |
190 | def action_button(%{click: _} = assigns) do
191 | ~H"""
192 |
193 | <%= render_slot(@inner_block) %>
194 |
195 | """
196 | end
197 |
198 | slot(:inner_block, required: true)
199 |
200 | def browser_window(assigns) do
201 | ~H"""
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 | <%= render_slot(@inner_block) %>
211 |
212 |
213 | """
214 | end
215 |
216 | defp show_content do
217 | %JS{}
218 | |> JS.set_attribute({"role", "dialog"})
219 | |> JS.remove_class("translate-x-full")
220 | end
221 |
222 | defp hide_content do
223 | %JS{}
224 | |> JS.remove_attribute("role")
225 | |> JS.transition("translate-x-full", time: 200)
226 | end
227 |
228 | defp show_backdrop do
229 | %JS{}
230 | |> JS.add_class("bg-opacity-50")
231 | |> JS.remove_class("bg-opacity-0")
232 | end
233 |
234 | defp hide_backdrop do
235 | JS.transition("bg-opacity-0", time: 200)
236 | end
237 |
238 | ## JS Commands
239 |
240 | def show(js \\ %JS{}, selector) do
241 | JS.show(js,
242 | to: selector,
243 | transition:
244 | {"transition-all transform ease-out duration-300",
245 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
246 | "opacity-100 translate-y-0 sm:scale-100"}
247 | )
248 | end
249 |
250 | def hide(js \\ %JS{}, selector) do
251 | JS.hide(js,
252 | to: selector,
253 | time: 200,
254 | transition:
255 | {"transition-all transform ease-in duration-200",
256 | "opacity-100 translate-y-0 sm:scale-100",
257 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
258 | )
259 | end
260 |
261 | defp action_button_class do
262 | "bg-gray-100 hover:text-gray-900 hover:bg-gray-200 transition-colors inline-flex items-center gap-x-1 justify-center px-5 py-2 text-sm font-medium text-gray-600 rounded"
263 | end
264 | end
265 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "castore": {:hex, :castore, "1.0.1", "240b9edb4e9e94f8f56ab39d8d2d0a57f49e46c56aced8f873892df8ff64ff5a", [:mix], [], "hexpm", "b4951de93c224d44fac71614beabd88b71932d0b1dea80d2f80fb9044e01bbb3"},
3 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
5 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
6 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
7 | "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"},
8 | "esbuild": {:hex, :esbuild, "0.6.1", "a774bfa7b4512a1211bf15880b462be12a4c48ed753a170c68c63b2c95888150", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "569f7409fb5a932211573fc20e2a930a0d5cf3377c5b4f6506c651b1783a1678"},
9 | "etso": {:hex, :etso, "1.1.0", "ddbf5417522ecc5f9544a5daeb67fc5f7509a5edb7f65add85a530dc35f80ec5", [:mix], [{:ecto, "~> 3.8.3", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "aa74f6bd76fb444aaa94554c668d637eedd6d71c0a9887ef973437ebe6645368"},
10 | "ex_union": {:hex, :ex_union, "0.1.2", "b11a8b7623b8bf45cc161c870684ff78df7d25d925c368246953823c0b5cba0e", [:mix], [], "hexpm", "3c7cae8d065e021e117ea8abf7e12c2a5bfd7c600039cd9a433f39ecfbc8fe00"},
11 | "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
13 | "floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"},
14 | "heroicons": {:hex, :heroicons, "0.5.2", "a7ae72460ecc4b74a4ba9e72f0b5ac3c6897ad08968258597da11c2b0b210683", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7ef96f455c1c136c335f1da0f1d7b12c34002c80a224ad96fc0ebf841a6ffef5"},
15 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
16 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
17 | "phoenix": {:hex, :phoenix, "1.7.1", "a029bde19d9c3b559e5c3d06c78b76e81396bedd456a6acedb42f9c7b2e535a9", [: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.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ea9d4a85c3592e37efa07d0dc013254fda445885facaefddcbf646375c116457"},
18 | "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
19 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
20 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.16", "781c6a3ac49e0451ca403848b40807171caea400896fe8ed8e5ddd6106ad5580", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "09e6ae2babe62f74bfcd1e3cac1a9b0e2c262557cc566300a843425c9cb6842a"},
21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
22 | "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"},
23 | "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
24 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
25 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
26 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
27 | "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
28 | "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
29 | "scrivener_list": {:hex, :scrivener_list, "2.0.1", "2b3b5c6aaf21d13b76071e755af498b641f37a069e34e68585ba4c624095d719", [:mix], [{:scrivener_ecto, "~> 1.0 or ~> 2.0", [hex: :scrivener_ecto, repo: "hexpm", optional: false]}], "hexpm", "dc82a317268e24b29891b2de659cd82d15654f49ceee86846de2c96b3c4f4e5d"},
30 | "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"},
31 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
32 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
33 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
34 | "websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"},
35 | "websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"},
36 | }
37 |
--------------------------------------------------------------------------------