diff --git a/apps/dust_api/lib/dust_api/handlers/network_handler.ex b/apps/dust_api/lib/dust_api/handlers/network_handler.ex new file mode 100644 index 0000000..63ffb2d --- /dev/null +++ b/apps/dust_api/lib/dust_api/handlers/network_handler.ex @@ -0,0 +1,35 @@ +defmodule Dust.Api.Handlers.NetworkHandler do + @moduledoc """ + Network startup endpoints. + + `POST /api/v1/network/start` lazily spawns the Go `tsnet_sidecar`. + + The bridge boots in **deferred mode** when no keystore exists on disk + so that Tailscale doesn't register the node under the placeholder + `node_name` (`"dust"`). `dustctl init` and the Web UI's `SetupLive` + call this endpoint AFTER the user has picked their device name, so + the sidecar's Tailscale identity is correct on its first connection. + + No-op (returns 200) when the sidecar is already running. + """ + + import Plug.Conn + + @doc "Start the Tailscale sidecar (or no-op if already running)." + @spec start(Plug.Conn.t()) :: Plug.Conn.t() + def start(conn) do + case Dust.Bridge.start_sidecar() do + :ok -> + json_response(conn, 200, %{status: "started"}) + + {:error, reason} -> + json_response(conn, 500, %{error: inspect(reason)}) + end + end + + defp json_response(conn, status, body) do + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end +end diff --git a/apps/dust_api/lib/dust_api/handlers/status_handler.ex b/apps/dust_api/lib/dust_api/handlers/status_handler.ex index dd3c15c..d3c1b53 100644 --- a/apps/dust_api/lib/dust_api/handlers/status_handler.ex +++ b/apps/dust_api/lib/dust_api/handlers/status_handler.ex @@ -20,6 +20,8 @@ defmodule Dust.Api.Handlers.StatusHandler do disk: disk_status(), network: network, persist_dir: Dust.Utilities.Config.persist_dir(), + ui_port: Dust.Utilities.Config.ui_port(), + ui_bind: Dust.Utilities.Config.ui_bind(), uptime_ms: :erlang.statistics(:wall_clock) |> elem(0), version: "0.1.5" } diff --git a/apps/dust_api/lib/dust_api/router.ex b/apps/dust_api/lib/dust_api/router.ex index c13e6a5..4fccbf0 100644 --- a/apps/dust_api/lib/dust_api/router.ex +++ b/apps/dust_api/lib/dust_api/router.ex @@ -112,6 +112,12 @@ defmodule Dust.Api.Router do Dust.Api.Handlers.ServiceHandler.stop(conn) end + # ── Network ──────────────────────────────────────────────────────────── + + post "/api/v1/network/start" do + Dust.Api.Handlers.NetworkHandler.start(conn) + end + # ── Garbage Collection ───────────────────────────────────────────────── get "/api/v1/gc/stats" do diff --git a/apps/dust_bridge/lib/dust_bridge.ex b/apps/dust_bridge/lib/dust_bridge.ex index 0a7b400..47abbac 100644 --- a/apps/dust_bridge/lib/dust_bridge.ex +++ b/apps/dust_bridge/lib/dust_bridge.ex @@ -36,6 +36,30 @@ defmodule Dust.Bridge do GenServer.call(__MODULE__, {:send_command, command}, timeout) end + @doc """ + Lazily spawns the Go `tsnet_sidecar` if the bridge was started in + deferred mode. + + Deferred mode is used during first-time setup: the bridge boots + without touching Tailscale so it doesn't register a hostname using + the placeholder `node_name` (`"dust"`). The init flow (CLI `dustctl + init` or the Web UI `SetupLive`) calls this after the user has chosen + their device name so the sidecar's Tailscale identity is correct on + its very first connection. + + Returns `:ok` if the sidecar is now running (or was already running). + """ + @spec start_sidecar() :: :ok | {:error, term()} + def start_sidecar do + GenServer.call(__MODULE__, :start_sidecar) + end + + @doc "True if the sidecar Port is open and accepting commands." + @spec sidecar_running?() :: boolean() + def sidecar_running? do + GenServer.call(__MODULE__, :sidecar_running?) + end + @doc """ Request the master key and OTP cookie from a peer node over Tailscale using a token. @@ -227,41 +251,43 @@ defmodule Dust.Bridge do # ── GenServer callbacks ───────────────────────────────────────────────── @impl true - @spec init(keyword()) :: {:ok, %{port: port()}} + @spec init(keyword()) :: {:ok, map()} def init(opts) do - sidecar = Keyword.get(opts, :sidecar_path, sidecar_path()) - state_dir = Keyword.get(opts, :ts_state_dir, Dust.Utilities.File.ts_state_dir()) + state = %{port: nil, opts: opts} + + if Keyword.get(opts, :defer, first_time_setup?()) do + # No keystore yet: defer sidecar startup so Tailscale doesn't get + # registered under the placeholder node_name ("dust"). The init + # flow will call `start_sidecar/0` after the user picks a name. + Logger.info("Bridge: deferred sidecar startup — waiting for first-time init to complete") + {:ok, state} + else + {:ok, open_sidecar_port(state)} + end + end - # The Tailscale hostname encodes the node's chosen name so peers can - # discover each other by name. Read directly from Config rather than - # parsing Node.self(), since the node atom is "dust@" and we - # want the host portion, not the constant "dust" prefix. - hostname = - System.get_env("TS_HOSTNAME") || "dust-node-#{Dust.Utilities.Config.node_name()}" + @impl true + def handle_call(:start_sidecar, _from, %{port: nil} = state) do + Logger.info("Bridge: opening sidecar port after first-time init") + new_state = open_sidecar_port(state) - ts_tags = - Keyword.get( - opts, - :ts_tags, - Application.get_env(:dust_bridge, :ts_tags, "tag:dust-node") - ) + # Re-trigger the one-shot port-exposure setup now that the sidecar + # is up; the Bridge.Setup task may have already run and failed. + _ = Task.start(fn -> Process.sleep(1_000); Dust.Bridge.Setup.run() end) - port = - Port.open({:spawn_executable, sidecar}, [ - :binary, - :exit_status, - {:packet, 4}, - env: [ - {~c"TS_HOSTNAME", to_charlist(hostname)}, - {~c"TS_STATE_DIR", to_charlist(state_dir)}, - {~c"TS_TAGS", to_charlist(ts_tags)} - ] - ]) + {:reply, :ok, new_state} + end - {:ok, %{port: port}} + def handle_call(:start_sidecar, _from, state), + do: {:reply, :ok, state} + + def handle_call(:sidecar_running?, _from, %{port: port} = state), + do: {:reply, is_port(port), state} + + def handle_call({:send_command, _command}, _from, %{port: nil} = state) do + {:reply, {:error, :sidecar_deferred}, state} end - @impl true def handle_call({:send_command, command}, _from, %{port: port} = state) do Port.command(port, command) @@ -278,7 +304,7 @@ defmodule Dust.Bridge do end @impl true - def handle_info({port, {:exit_status, code}}, %{port: port} = state) do + def handle_info({port, {:exit_status, code}}, %{port: port} = state) when is_port(port) do Logger.error("Bridge: Go sidecar exited with code #{code}") {:stop, {:sidecar_exited, code}, state} end @@ -287,6 +313,42 @@ defmodule Dust.Bridge do # ── Private ───────────────────────────────────────────────────────────── + defp open_sidecar_port(state) do + opts = Map.get(state, :opts, []) + sidecar = Keyword.get(opts, :sidecar_path, sidecar_path()) + state_dir = Keyword.get(opts, :ts_state_dir, Dust.Utilities.File.ts_state_dir()) + + # Read node_name at port-open time — for deferred starts this happens + # after the user has picked their device name during init. + hostname = + System.get_env("TS_HOSTNAME") || "dust-node-#{Dust.Utilities.Config.node_name()}" + + ts_tags = + Keyword.get( + opts, + :ts_tags, + Application.get_env(:dust_bridge, :ts_tags, "tag:dust-node") + ) + + port = + Port.open({:spawn_executable, sidecar}, [ + :binary, + :exit_status, + {:packet, 4}, + env: [ + {~c"TS_HOSTNAME", to_charlist(hostname)}, + {~c"TS_STATE_DIR", to_charlist(state_dir)}, + {~c"TS_TAGS", to_charlist(ts_tags)} + ] + ]) + + %{state | port: port} + end + + defp first_time_setup? do + not File.exists?(Dust.Utilities.File.master_key_file()) + end + @spec sidecar_path() :: Path.t() defp sidecar_path do binary_name = diff --git a/apps/dust_bridge/test/bridge_test.exs b/apps/dust_bridge/test/bridge_test.exs index 3080e7e..628aff8 100644 --- a/apps/dust_bridge/test/bridge_test.exs +++ b/apps/dust_bridge/test/bridge_test.exs @@ -29,9 +29,13 @@ defmodule Dust.BridgeTest do File.chmod!(wrapper, 0o755) - # Start the Bridge GenServer with the fake sidecar + # Start the Bridge GenServer with the fake sidecar. `defer: false` + # bypasses the first-time-setup check that would normally hold the + # Port closed until `Dust.Bridge.start_sidecar/0` is called. pid = - start_supervised!({Dust.Bridge, [sidecar_path: wrapper, ts_state_dir: System.tmp_dir!()]}) + start_supervised!( + {Dust.Bridge, [sidecar_path: wrapper, ts_state_dir: System.tmp_dir!(), defer: false]} + ) # Give the script a moment to boot Process.sleep(200) diff --git a/apps/dust_cli/lib/dust_cli.ex b/apps/dust_cli/lib/dust_cli.ex index 3da4304..f2d73a7 100644 --- a/apps/dust_cli/lib/dust_cli.ex +++ b/apps/dust_cli/lib/dust_cli.ex @@ -46,6 +46,9 @@ defmodule Dust.CLI do gc stats Show garbage collection statistics gc sweep Trigger a manual GC sweep + ui open Open the web UI in your browser + ui status Show the web UI URL and reachability + help Show this help message version Show version @@ -62,7 +65,7 @@ defmodule Dust.CLI do @version "0.1.5" # Commands that DO NOT require Tailscale connectivity - @no_network_required ~w(init status auth daemon unlock lock config help version) + @no_network_required ~w(init status auth daemon unlock lock config help version ui) @doc false def run(args) do @@ -197,6 +200,8 @@ defmodule Dust.CLI do defp dispatch({config, ["gc" | args]}), do: Commands.Gc.run(config, args) + defp dispatch({config, ["ui" | args]}), do: Commands.Ui.run(config, args) + defp dispatch({_config, ["version" | _]}) do IO.puts("dustctl #{@version}") 0 @@ -255,6 +260,10 @@ defmodule Dust.CLI do {"gc stats", "Show garbage collection statistics"}, {"gc sweep", "Trigger a manual GC sweep"} ]}, + {"Web UI", [ + {"ui open", "Open the web UI in your browser"}, + {"ui status", "Show the web UI URL and reachability"} + ]}, {"Other", [ {"help", "Show this help message"}, {"version", "Show version"} diff --git a/apps/dust_cli/lib/dust_cli/commands/init.ex b/apps/dust_cli/lib/dust_cli/commands/init.ex index 5a39474..0c9e630 100644 --- a/apps/dust_cli/lib/dust_cli/commands/init.ex +++ b/apps/dust_cli/lib/dust_cli/commands/init.ex @@ -65,7 +65,14 @@ defmodule Dust.CLI.Commands.Init do IO.puts("") - # Step 3: Unlock key store + # Step 3: Node name (must happen BEFORE Tailscale starts — changing + # the name later forces a re-auth because Tailscale identity is keyed + # by hostname). + setup_node_name(config) + + IO.puts("") + + # Step 4: Unlock key store IO.puts(" Checking key store...") case Client.get(config, "/api/v1/status") do @@ -105,12 +112,12 @@ defmodule Dust.CLI.Commands.Init do IO.puts("") - # Step 4: Node name - setup_node_name(config) + # Step 5: Bring up Tailscale now that node_name is finalized. + start_network(config) IO.puts("") - # Step 5: Network setup + # Step 6: Network setup Formatter.heading("Network Setup") IO.puts("") @@ -202,6 +209,83 @@ defmodule Dust.CLI.Commands.Init do defp valid_node_name?(_), do: false + # ── Bring up Tailscale ──────────────────────────────────────────────── + + defp start_network(config) do + IO.puts(" Starting Tailscale…") + + case Client.post(config, "/api/v1/network/start", %{}) do + {200, _} -> + Formatter.success("Tailscale sidecar started") + IO.puts("") + wait_for_tailscale(config) + + other -> + Formatter.warning("Could not start Tailscale sidecar: #{inspect(other)}") + Formatter.info("You may need to run 'dustctl init' again after fixing the issue.") + end + end + + # Cold-starting the Go sidecar (load tsnet state, register with control + # plane, mint a login URL) routinely takes 15–45 s. Poll long enough to + # surface either the auth URL or the connected state inline so the user + # doesn't have to follow up with `dustctl auth`. + @tailscale_poll_total_s 45 + @tailscale_poll_interval_ms 2_000 + + defp wait_for_tailscale(config) do + Owl.Spinner.start(id: :ts_init, labels: [processing: "Reaching Tailscale…"]) + + case poll_tailscale(config, @tailscale_poll_total_s) do + {:authenticated, self_ip} -> + spinner_stop(id: :ts_init, resolution: :ok, label: "Tailscale connected (#{self_ip})") + :ok + + {:auth_url, url} -> + spinner_stop(id: :ts_init, resolution: :ok, label: "Tailscale auth URL is ready") + IO.puts("") + + Formatter.info_box("Tailscale Auth", [ + "Open this URL on any device to authenticate this node:\n\n", + Owl.Data.tag(" " <> url, [:cyan, :underline]) + ]) + + IO.puts("") + Formatter.info("Run 'dustctl auth' to wait for authentication.") + :ok + + :still_starting -> + spinner_stop(id: :ts_init, resolution: :error, label: "Tailscale did not respond in time") + Formatter.info("Run 'dustctl auth' shortly to retrieve the login URL.") + :ok + end + end + + defp poll_tailscale(_config, remaining_s) when remaining_s <= 0, do: :still_starting + + defp poll_tailscale(config, remaining_s) do + :timer.sleep(@tailscale_poll_interval_ms) + + case Client.get(config, "/api/v1/status") do + {200, {:ok, %{"network" => %{"connected" => true, "self_ip" => ip}}}} + when is_binary(ip) and ip != "" -> + {:authenticated, ip} + + {200, {:ok, %{"network" => %{"auth_url" => url}}}} + when is_binary(url) and url != "" -> + {:auth_url, url} + + _ -> + poll_tailscale(config, remaining_s - div(@tailscale_poll_interval_ms, 1_000)) + end + end + + defp spinner_stop(opts) do + Owl.Spinner.stop(opts) + rescue + _ -> :ok + end + # ── New network ──────────────────────────────────────────────────────── defp setup_new_network(config) do diff --git a/apps/dust_cli/lib/dust_cli/commands/network.ex b/apps/dust_cli/lib/dust_cli/commands/network.ex index 46df576..7b7ae35 100644 --- a/apps/dust_cli/lib/dust_cli/commands/network.ex +++ b/apps/dust_cli/lib/dust_cli/commands/network.ex @@ -35,7 +35,10 @@ defmodule Dust.CLI.Commands.Network do auth_url else Owl.Spinner.start(id: :auth_poll, labels: [processing: "Checking for login URL..."]) - url = poll_for_auth_url(config, 15) + # Cold sidecar starts can take 30–45s before tsnet hands out a + # login URL; poll long enough to ride through that without + # falsely telling the user nothing is happening. + url = poll_for_auth_url(config, 60) spinner_stop(id: :auth_poll, resolution: :ok) url end diff --git a/apps/dust_cli/lib/dust_cli/commands/ui.ex b/apps/dust_cli/lib/dust_cli/commands/ui.ex new file mode 100644 index 0000000..4d98cc1 --- /dev/null +++ b/apps/dust_cli/lib/dust_cli/commands/ui.ex @@ -0,0 +1,105 @@ +defmodule Dust.CLI.Commands.Ui do + @moduledoc false + + alias Dust.CLI.{Client, Formatter} + + def run(config, []), do: open(config, []) + def run(config, ["open" | args]), do: open(config, args) + def run(config, ["status" | args]), do: status(config, args) + + def run(_config, [unknown | _]) do + Formatter.error("Unknown ui subcommand: #{unknown}") + Formatter.info("Available: open, status") + 1 + end + + defp open(config, _args) do + case ui_url(config) do + {:ok, url} -> + case launch_browser(url) do + :ok -> + Formatter.success("Opened #{url}") + 0 + + {:error, reason} -> + Formatter.warning("Could not open browser: #{reason}") + Formatter.info(url) + 0 + end + + {:error, reason} -> + Formatter.error(reason) + 1 + end + end + + defp status(config, _args) do + case ui_url(config) do + {:ok, url} -> + Formatter.kv([{"URL", url}, {"Reachable", reachability(url)}]) + 0 + + {:error, reason} -> + Formatter.error(reason) + 1 + end + end + + # ── URL resolution ─────────────────────────────────────────────────── + + defp ui_url(config) do + case Client.get(config, "/api/v1/status") do + {200, {:ok, body}} -> + port = Map.get(body, "ui_port") || 4885 + bind = Map.get(body, "ui_bind") || "127.0.0.1" + host = display_host(bind) + {:ok, "http://#{host}:#{port}"} + + {:error, {:failed_connect, _}} -> + {:error, "Daemon is unreachable. Start it with `dustctl daemon start`."} + + other -> + {:error, "Could not query daemon status: #{inspect(other)}"} + end + end + + # Browsers don't accept "0.0.0.0" — substitute with localhost. + defp display_host("0.0.0.0"), do: "127.0.0.1" + defp display_host(""), do: "127.0.0.1" + defp display_host(bind), do: bind + + # ── Browser launching ──────────────────────────────────────────────── + + defp launch_browser(url) do + {cmd, args} = browser_open_command(url) + + case System.cmd(cmd, args, stderr_to_stdout: true) do + {_out, 0} -> :ok + {out, status} -> {:error, "#{cmd} exited #{status}: #{String.trim(out)}"} + end + rescue + e in ErlangError -> {:error, Exception.message(e)} + end + + defp browser_open_command(url) do + case :os.type() do + {:unix, :darwin} -> {"open", [url]} + {:win32, _} -> {"cmd", ["/c", "start", "", url]} + {:unix, _} -> {"xdg-open", [url]} + end + end + + # ── Reachability check ─────────────────────────────────────────────── + + defp reachability(url) do + request = {String.to_charlist(url), []} + + case :httpc.request(:head, request, [timeout: 1000, connect_timeout: 500], []) do + {:ok, {{_, status, _}, _, _}} when status in 200..399 -> "yes (HTTP #{status})" + {:ok, {{_, status, _}, _, _}} -> "responded HTTP #{status}" + {:error, reason} -> "no (#{inspect(reason)})" + end + rescue + _ -> "no" + end +end diff --git a/apps/dust_core/lib/dust_core/fitness.ex b/apps/dust_core/lib/dust_core/fitness.ex index fe9b1a4..6b795c4 100644 --- a/apps/dust_core/lib/dust_core/fitness.ex +++ b/apps/dust_core/lib/dust_core/fitness.ex @@ -212,4 +212,20 @@ defmodule Dust.Core.Fitness do def record(node_id, observation) when is_atom(node_id) do ModelStore.update(node_id, observation) end + + @doc """ + List every node for which a fitness model is currently stored. + + Returns `[{node(), NodeEMA.t()}]`. Order is unspecified. Reads directly + from the public ETS table, so it is lock-free and safe at any time. + Nodes that have never been interacted with are NOT in the list — call + `score/1` to get a default model for an unseen node. + """ + @spec list() :: [{node(), NodeEMA.t()}] + def list do + case :ets.whereis(:fitness_models) do + :undefined -> [] + _tid -> :ets.tab2list(:fitness_models) + end + end end diff --git a/apps/dust_daemon/lib/dust_daemon/bootstrapper.ex b/apps/dust_daemon/lib/dust_daemon/bootstrapper.ex index ba84bdd..b18070b 100644 --- a/apps/dust_daemon/lib/dust_daemon/bootstrapper.ex +++ b/apps/dust_daemon/lib/dust_daemon/bootstrapper.ex @@ -95,15 +95,33 @@ defmodule Dust.Daemon.Bootstrapper do @spec await_bridge_ready() :: :ok defp await_bridge_ready do - if bridge_disabled?() do - Logger.info("Bootstrapper [1/5]: bridge sidecar disabled — skipping health check") - :ok - else - Logger.info("Bootstrapper [1/5]: checking bridge sidecar health…") - do_await_bridge(1) + cond do + bridge_disabled?() -> + Logger.info("Bootstrapper [1/5]: bridge sidecar disabled — skipping health check") + :ok + + bridge_deferred?() -> + Logger.info( + "Bootstrapper [1/5]: bridge sidecar deferred (first-time setup) — skipping health check" + ) + + :ok + + true -> + Logger.info("Bootstrapper [1/5]: checking bridge sidecar health…") + do_await_bridge(1) end end + @spec bridge_deferred?() :: boolean() + defp bridge_deferred? do + Dust.Bridge.sidecar_running?() == false + rescue + _ -> false + catch + :exit, _ -> false + end + @spec do_await_bridge(pos_integer()) :: :ok defp do_await_bridge(attempt) when attempt > @bridge_max_retries do Logger.error( diff --git a/apps/dust_daemon/lib/dust_daemon/disk_manager.ex b/apps/dust_daemon/lib/dust_daemon/disk_manager.ex index e425aaf..5e99c5c 100644 --- a/apps/dust_daemon/lib/dust_daemon/disk_manager.ex +++ b/apps/dust_daemon/lib/dust_daemon/disk_manager.ex @@ -58,6 +58,17 @@ defmodule Dust.Daemon.DiskManager do end end + @doc """ + Current size of the local shard store on disk, in bytes. + + Walks the storage backend directory tree. Cheap enough to call on a + dashboard refresh tick (10s); avoid hot paths. + """ + @spec usage_bytes() :: non_neg_integer() + def usage_bytes do + dir_size(Dust.Utilities.File.storage_db_dir()) + end + @spec dir_size(String.t()) :: non_neg_integer() defp dir_size(path) do case File.ls(path) do diff --git a/apps/dust_daemon/test/test_helper.exs b/apps/dust_daemon/test/test_helper.exs index 69f08f4..ff4e37a 100644 --- a/apps/dust_daemon/test/test_helper.exs +++ b/apps/dust_daemon/test/test_helper.exs @@ -6,6 +6,14 @@ end Application.put_env(:dust_bridge, :bridge_module, Dust.Bridge.Mock) +# Remove any stale master.key persisted by a previous test run with a +# different password — otherwise KeyStore.unlock/1 silently fails with +# {:error, :decrypt_failed} and downstream encrypt_with_master/1 raises. +File.rm(Dust.Utilities.File.master_key_file()) + +# Unlock the KeyStore once so every test that does encryption can run. +:ok = Dust.Core.KeyStore.unlock("test_password_123") + # The bootstrapper does not run in the test environment, so mark # the system as ready so sweep guards in GC/RepairScheduler pass. Dust.Daemon.Readiness.set_ready() diff --git a/apps/dust_mesh/lib/dust_mesh/file_system.ex b/apps/dust_mesh/lib/dust_mesh/file_system.ex index 29a08c4..4f47dd4 100644 --- a/apps/dust_mesh/lib/dust_mesh/file_system.ex +++ b/apps/dust_mesh/lib/dust_mesh/file_system.ex @@ -404,7 +404,16 @@ defmodule Dust.Mesh.FileSystem do end end - @doc "Deletes a file entirely. (Accepts dir_id for legacy API compliance)." + @doc """ + Deletes a file entirely. + + Removes the directory-listing entry from `FileMap` AND drops the + corresponding `Manifest.FileIndex` entry so the garbage collector + treats the chunks as orphans on its next sweep. `Manifest.remove_file/2` + also cleans `ChunkIndex` and `ShardMap` entries for any chunk no + longer referenced by any other file. (Accepts dir_id for legacy API + compliance.) + """ @spec rm_file(uuid(), uuid() | nil) :: :ok | {:error, :not_found | :crdt_unavailable} def rm_file(file_id, _dir_id \\ nil) when is_binary(file_id) do case FileMap.get(file_id) do @@ -412,6 +421,10 @@ defmodule Dust.Mesh.FileSystem do {:error, :not_found} _meta -> + # Best-effort manifest cleanup. `remove_file` returns `{:error, :not_found}` + # for uploads that failed before populating FileIndex, which is fine — + # the FileMap entry should still be removed. + _ = Dust.Mesh.Manifest.remove_file(file_id, Dust.Utilities.Config.total_shards()) FileMap.delete(file_id) end end diff --git a/apps/dust_mesh/mix.exs b/apps/dust_mesh/mix.exs index e2fb43c..83e61a9 100644 --- a/apps/dust_mesh/mix.exs +++ b/apps/dust_mesh/mix.exs @@ -29,6 +29,7 @@ defmodule Dust.Mesh.MixProject do {:delta_crdt, "~> 0.6.5"}, {:cubdb, "~> 2.0.2"}, {:dust_core, in_umbrella: true}, + {:dust_utilities, in_umbrella: true}, {:mox, "~> 1.0", only: :test} ] end diff --git a/apps/dust_mesh/test/file_system_test.exs b/apps/dust_mesh/test/file_system_test.exs index c4a3b49..98b908e 100644 --- a/apps/dust_mesh/test/file_system_test.exs +++ b/apps/dust_mesh/test/file_system_test.exs @@ -16,6 +16,9 @@ defmodule Dust.Mesh.FileSystemTest do start_supervised!(Dust.Mesh.NodeRegistry) start_supervised!(Dust.Mesh.FileSystem.DirMap) start_supervised!(Dust.Mesh.FileSystem.FileMap) + start_supervised!(Dust.Mesh.Manifest.FileIndex) + start_supervised!(Dust.Mesh.Manifest.ChunkIndex) + start_supervised!(Dust.Mesh.Manifest.ShardMap) end setup_all do diff --git a/apps/dust_ui/assets/css/app.css b/apps/dust_ui/assets/css/app.css new file mode 100644 index 0000000..da60a94 --- /dev/null +++ b/apps/dust_ui/assets/css/app.css @@ -0,0 +1,9 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* App-wide tweaks live here. */ +html, +body { + background-color: rgb(250 250 250); +} diff --git a/apps/dust_ui/assets/js/app.js b/apps/dust_ui/assets/js/app.js new file mode 100644 index 0000000..cf0bf1d --- /dev/null +++ b/apps/dust_ui/assets/js/app.js @@ -0,0 +1,21 @@ +import "phoenix_html" +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" +import topbar from "../vendor/topbar" + +const csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content") + +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: { _csrf_token: csrfToken }, +}) + +// Page-load progress bar +topbar.config({ barColors: { 0: "#18181b" }, shadowColor: "rgba(0, 0, 0, .3)" }) +window.addEventListener("phx:page-loading-start", () => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", () => topbar.hide()) + +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/apps/dust_ui/assets/tailwind.config.js b/apps/dust_ui/assets/tailwind.config.js new file mode 100644 index 0000000..937a920 --- /dev/null +++ b/apps/dust_ui/assets/tailwind.config.js @@ -0,0 +1,22 @@ +const plugin = require("tailwindcss/plugin") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/dust_ui.ex", + "../lib/dust_ui/**/*.{ex,heex}", + ], + theme: { + extend: { + colors: { + brand: "#18181b", + }, + }, + }, + plugins: [ + plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + ], +} diff --git a/apps/dust_ui/assets/vendor/topbar.js b/apps/dust_ui/assets/vendor/topbar.js new file mode 100644 index 0000000..e9bc3d2 --- /dev/null +++ b/apps/dust_ui/assets/vendor/topbar.js @@ -0,0 +1,153 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { 0: "rgba(26, 188, 156, .9)", ".25": "rgba(52, 152, 219, .9)", ".50": "rgba(241, 196, 15, .9)", ".75": "rgba(230, 126, 34, .9)", "1.0": "rgba(211, 84, 0, .9)" }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (progressTimerId) return; + progressTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(progressTimerId); + progressTimerId = null; + if (!showing) return; + showing = false; + if (canvas != null) { + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + } + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/dust_ui/lib/dust_ui.ex b/apps/dust_ui/lib/dust_ui.ex index cba96da..8588a8f 100644 --- a/apps/dust_ui/lib/dust_ui.ex +++ b/apps/dust_ui/lib/dust_ui.ex @@ -5,5 +5,82 @@ defmodule Dust.Ui do Provides the front-end through which users interact with the distributed file system — managing files, unlocking the key store, and monitoring node status. + + This module also hosts the imports used by controllers, LiveViews, and + components in `apps/dust_ui/lib/dust_ui/`. """ + + def static_paths, + do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: Dust.Ui.Layouts] + + import Plug.Conn + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, layout: {Dust.Ui.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + + import Dust.Ui.CoreComponents + alias Phoenix.LiveView.JS + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end end diff --git a/apps/dust_ui/lib/dust_ui/application.ex b/apps/dust_ui/lib/dust_ui/application.ex new file mode 100644 index 0000000..a386aac --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/application.ex @@ -0,0 +1,99 @@ +defmodule Dust.Ui.Application do + @moduledoc """ + OTP application for the Dust web UI (Phoenix LiveView). + + Resolves the runtime bind/port from `Dust.Utilities.Config`, ensures a + persistent `secret_key_base` exists on disk, then starts the Phoenix + PubSub server, telemetry supervisor, and the endpoint under supervision. + """ + + use Application + + require Logger + + @impl true + def start(_type, _args) do + configure_endpoint!() + + children = [ + Dust.Ui.Telemetry, + {Phoenix.PubSub, name: Dust.Ui.PubSub}, + Dust.Ui.Endpoint + ] + + Logger.info( + "Dust.Ui: starting on http://#{Dust.Utilities.Config.ui_bind()}:#{Dust.Utilities.Config.ui_port()}" + ) + + opts = [strategy: :one_for_one, name: Dust.Ui.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + Dust.Ui.Endpoint.config_change(changed, removed) + :ok + end + + # Resolve runtime config (port, bind, secret_key_base) into the endpoint env + # before the endpoint child boots. + defp configure_endpoint!() do + port = Dust.Utilities.Config.ui_port() + ip = parse_bind(Dust.Utilities.Config.ui_bind()) + secret = ensure_secret_key_base!() + + existing = Application.get_env(:dust_ui, Dust.Ui.Endpoint, []) + + http = + existing + |> Keyword.get(:http, []) + |> Keyword.put(:ip, ip) + |> Keyword.put(:port, port) + + merged = + existing + |> Keyword.put(:http, http) + |> Keyword.put(:secret_key_base, secret) + # The UI exists to be served — start the HTTP listener unconditionally. + # Test env keeps :server false unless explicitly opted in to avoid port + # collisions during async test runs. + |> Keyword.put_new(:server, true) + + Application.put_env(:dust_ui, Dust.Ui.Endpoint, merged) + end + + defp parse_bind(bind_str) do + case :inet.parse_address(to_charlist(bind_str)) do + {:ok, ip} -> ip + {:error, _} -> {127, 0, 0, 1} + end + end + + # Persist a 64-byte hex secret to /ui_secret_key_base on first + # boot. Reused on subsequent boots so signed sessions survive restarts. + defp ensure_secret_key_base!() do + path = Path.join(Dust.Utilities.Config.persist_dir(), "ui_secret_key_base") + + case File.read(path) do + {:ok, contents} -> + secret = String.trim(contents) + + if byte_size(secret) >= 64 do + secret + else + write_secret!(path) + end + + {:error, :enoent} -> + write_secret!(path) + end + end + + defp write_secret!(path) do + secret = 48 |> :crypto.strong_rand_bytes() |> Base.encode64() + File.mkdir_p!(Path.dirname(path)) + File.write!(path, secret) + _ = File.chmod(path, 0o600) + secret + end +end diff --git a/apps/dust_ui/lib/dust_ui/auth/keystore.ex b/apps/dust_ui/lib/dust_ui/auth/keystore.ex new file mode 100644 index 0000000..4feca0e --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/keystore.ex @@ -0,0 +1,34 @@ +defmodule Dust.Ui.Auth.Keystore do + @moduledoc """ + Thin wrapper around `Dust.Core.KeyStore` for the web UI. + + Mirrors the unlock/lock semantics of `Dust.Api.Handlers.KeystoreHandler` so + that logging in through the UI has the same effect as `dustctl unlock`. + """ + + @doc """ + Attempts to unlock the keystore with the given password. + + Returns `:ok` on success, `{:error, :invalid_password}` for a wrong + password, or `{:error, reason}` for anything else. + """ + @spec unlock(String.t()) :: :ok | {:error, :invalid_password | term()} + def unlock(password) when is_binary(password) and password != "" do + case Dust.Core.KeyStore.unlock(password) do + :ok -> :ok + {:error, :decrypt_failed} -> {:error, :invalid_password} + {:error, :already_unlocked} -> :ok + {:error, reason} -> {:error, reason} + end + end + + def unlock(_), do: {:error, :invalid_password} + + @doc "Locks the keystore." + @spec lock() :: :ok + def lock, do: Dust.Core.KeyStore.lock() + + @doc "Returns true if the keystore is currently unlocked." + @spec unlocked?() :: boolean() + def unlocked?, do: Dust.Core.KeyStore.has_key?() +end diff --git a/apps/dust_ui/lib/dust_ui/auth/live_auth.ex b/apps/dust_ui/lib/dust_ui/auth/live_auth.ex new file mode 100644 index 0000000..c584633 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/live_auth.ex @@ -0,0 +1,25 @@ +defmodule Dust.Ui.Auth.LiveAuth do + @moduledoc """ + LiveView `on_mount` hook that enforces an authenticated session. + + Used in a `live_session` block in the router. Halts the mount and + redirects to `/login` when: + + * the session does not carry `:authenticated`, OR + * the keystore has been locked out of band (CLI / API). + """ + + import Phoenix.LiveView + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + def on_mount(:default, _params, session, socket) do + if session["authenticated"] == true and Dust.Ui.Auth.Keystore.unlocked?() do + {:cont, socket} + else + {:halt, redirect(socket, to: ~p"/login")} + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/auth/session.ex b/apps/dust_ui/lib/dust_ui/auth/session.ex new file mode 100644 index 0000000..fedb4d0 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/session.ex @@ -0,0 +1,82 @@ +defmodule Dust.Ui.Auth.Session do + @moduledoc """ + Session helpers and plugs for UI authentication. + + Authentication is bound to the keystore: a user is considered logged in + while the session carries `:authenticated` AND the keystore reports + `Dust.Ui.Auth.Keystore.unlocked?/0`. If the keystore is locked from + another source (CLI, API), the next request bounces back to `/login`. + """ + + import Plug.Conn + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + @session_key :authenticated + + @doc "Marks the connection as logged in after a successful unlock." + @spec log_in(Plug.Conn.t()) :: Plug.Conn.t() + def log_in(conn) do + conn + |> renew_session() + |> put_session(@session_key, true) + end + + @doc "Clears any authentication state from the session." + @spec log_out(Plug.Conn.t()) :: Plug.Conn.t() + def log_out(conn) do + conn + |> configure_session(drop: true) + end + + @doc "Returns true if the session is authenticated and the keystore is unlocked." + @spec authenticated?(Plug.Conn.t()) :: boolean() + def authenticated?(conn) do + get_session(conn, @session_key) == true and Dust.Ui.Auth.Keystore.unlocked?() + end + + # ── Plug behaviour ─────────────────────────────────────────────────── + + @doc false + def init(action) when action in [:require_authenticated, :redirect_if_authenticated], + do: action + + @doc false + def call(conn, :require_authenticated), do: require_authenticated(conn, []) + def call(conn, :redirect_if_authenticated), do: redirect_if_authenticated(conn, []) + + # ── Plug actions ───────────────────────────────────────────────────── + + @doc "Plug: redirect to /login if the session is not authenticated." + def require_authenticated(conn, _opts) do + if authenticated?(conn) do + conn + else + conn + |> log_out() + |> Phoenix.Controller.redirect(to: ~p"/login") + |> halt() + end + end + + @doc """ + Plug: redirect already-authenticated visitors away from the login page. + """ + def redirect_if_authenticated(conn, _opts) do + if authenticated?(conn) do + conn + |> Phoenix.Controller.redirect(to: ~p"/") + |> halt() + else + conn + end + end + + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/core_components.ex b/apps/dust_ui/lib/dust_ui/components/core_components.ex new file mode 100644 index 0000000..8d22e85 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/core_components.ex @@ -0,0 +1,150 @@ +defmodule Dust.Ui.CoreComponents do + @moduledoc """ + Minimal core component library for the Dust web UI. + + Provides building blocks (`button`, `input`, `flash`) used across the + login page and dashboards. Styling is Tailwind utility classes. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a flash notice (`:info` or `:error`). + """ + attr :kind, :atom, values: [:info, :error], doc: "the kind of flash" + attr :flash, :map, default: %{}, doc: "the map of flash messages" + attr :title, :string, default: nil + attr :id, :string, default: nil + slot :inner_block + + def flash(assigns) do + ~H""" + + """ + end + + @doc "Renders a group of flash messages from `@flash`." + attr :flash, :map, required: true + + def flash_group(assigns) do + ~H""" + <.flash kind={:info} title="Heads up" flash={@flash} /> + <.flash kind={:error} title="Error" flash={@flash} /> + """ + end + + @doc """ + Renders a styled button. Either drives a `phx-click` event or, with + `type="submit"`, submits the surrounding form. + """ + attr :type, :string, default: "button" + attr :class, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value) + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a labelled form input. + """ + attr :id, :any, default: nil + attr :name, :any + attr :label, :string, default: nil + attr :value, :any, default: nil + attr :type, :string, default: "text" + attr :errors, :list, default: [] + attr :rest, :global, include: ~w(autocomplete placeholder required) + + def input(assigns) do + ~H""" +
+ + +

{msg}

+
+ """ + end + + @doc """ + Hides an element with a Phoenix LiveView JS transition. + """ + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + end + + @doc """ + A simple modal overlay. Dismiss-on-backdrop-click sends `on_close` to + the parent LiveView. + """ + attr :title, :string, default: nil + attr :on_close, :string, default: "close_modal" + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/error_html.ex b/apps/dust_ui/lib/dust_ui/components/error_html.ex new file mode 100644 index 0000000..cbcd176 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/error_html.ex @@ -0,0 +1,8 @@ +defmodule Dust.Ui.ErrorHTML do + @moduledoc false + use Dust.Ui, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/error_json.ex b/apps/dust_ui/lib/dust_ui/components/error_json.ex new file mode 100644 index 0000000..862c555 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/error_json.ex @@ -0,0 +1,7 @@ +defmodule Dust.Ui.ErrorJSON do + @moduledoc false + + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/file_table.ex b/apps/dust_ui/lib/dust_ui/components/file_table.ex new file mode 100644 index 0000000..e98699a --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/file_table.ex @@ -0,0 +1,260 @@ +defmodule Dust.Ui.FileTable do + @moduledoc false + use Phoenix.Component + + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + import Dust.Ui.CoreComponents + + alias Dust.Ui.Format + + @doc """ + Breadcrumb trail. `trail` is a list of `%{id, name}` from root to + current. The last entry is rendered un-linked. + """ + attr :trail, :list, required: true + + def breadcrumbs(assigns) do + ~H""" + + """ + end + + @doc """ + Renders the directory/file table. + """ + attr :dirs, :list, required: true + attr :files, :list, required: true + attr :progress, :map, default: %{} + + def file_table(assigns) do + ~H""" +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Size + + Created + + Actions +
+ This directory is empty. +
+ <.link patch={~p"/files/#{dir.id}"} class="flex items-center gap-2 font-medium text-zinc-900 hover:underline"> + 📁 + {dir.name} + + {Format.relative_time(Map.get(dir, :created_at))} +
+ + + +
+
+
+ 📄 + {file.name} +
+
+
+
+
+
+

+ {progress_label(p)} · {p.chunk}/{p.total} +

+
+
+ {Format.bytes(parse_int(Map.get(file, :size)))} + {Format.relative_time(Map.get(file, :created_at))} +
+ <.link + href={~p"/download/#{file.id}"} + class="text-zinc-700 hover:text-zinc-900" + > + Download + + + + +
+
+
+ """ + end + + defp parse_int(nil), do: nil + defp parse_int(n) when is_integer(n), do: n + + defp parse_int(s) when is_binary(s) do + case Integer.parse(s) do + {n, _} -> n + _ -> nil + end + end + + defp parse_int(_), do: nil + + defp progress_percent(%{chunk: c, total: t}) when is_integer(t) and t > 0, + do: trunc(c * 100 / t) |> min(100) |> max(0) + + defp progress_percent(_), do: 0 + + defp progress_label(%{kind: :upload}), do: "Uploading" + defp progress_label(%{kind: :download}), do: "Downloading" + defp progress_label(_), do: "" + + @doc "Renders the drag-and-drop upload dropzone with a submit form." + attr :uploads, :map, required: true + + def upload_dropzone(assigns) do + ~H""" +
+

+ Drop files here or + +

+ + <%= for entry <- @uploads.files.entries do %> +
+

{entry.client_name}

+
+
+
+

+ {msg} +

+
+ <% end %> + +
+ <.button type="submit" class="bg-emerald-600 hover:bg-emerald-700">Upload +
+
+ """ + end + + defp upload_errors_for_entry(config, entry) do + case Phoenix.Component.upload_errors(config, entry) do + [] -> nil + [err | _] -> humanize_upload_error(err) + end + end + + defp humanize_upload_error(:too_large), do: "File is too large." + defp humanize_upload_error(:not_accepted), do: "File type not accepted." + defp humanize_upload_error(:too_many_files), do: "Too many files." + defp humanize_upload_error(err), do: inspect(err) +end diff --git a/apps/dust_ui/lib/dust_ui/components/format.ex b/apps/dust_ui/lib/dust_ui/components/format.ex new file mode 100644 index 0000000..466f691 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/format.ex @@ -0,0 +1,69 @@ +defmodule Dust.Ui.Format do + @moduledoc """ + Display-format helpers shared across LiveViews and components. + """ + + @units ["B", "KB", "MB", "GB", "TB", "PB"] + + @doc """ + Formats a byte count as a human-readable string (1.4 GB, 532 KB, …). + """ + @spec bytes(integer() | nil) :: String.t() + def bytes(nil), do: "—" + def bytes(0), do: "0 B" + + def bytes(n) when is_integer(n) and n > 0 do + exp = min(trunc(:math.log(n) / :math.log(1024)), length(@units) - 1) + val = n / :math.pow(1024, exp) + unit = Enum.at(@units, exp) + "#{:erlang.float_to_binary(val, decimals: precision(val))} #{unit}" + end + + def bytes(_), do: "—" + + defp precision(v) when v >= 100, do: 0 + defp precision(v) when v >= 10, do: 1 + defp precision(_), do: 2 + + @doc """ + Formats a percentage (0.0..1.0) clamped and rounded. + """ + @spec percent(float() | integer() | nil, integer()) :: String.t() + def percent(ratio, decimals \\ 0) + def percent(nil, _decimals), do: "—" + def percent(0, _decimals), do: "0%" + + def percent(ratio, decimals) when is_number(ratio) do + clamped = ratio |> min(1.0) |> max(0.0) + "#{:erlang.float_to_binary(clamped * 100.0, decimals: decimals)}%" + end + + @doc """ + Returns a relative timestamp like "12s ago", "3m ago", "2h ago". + Accepts a DateTime, NaiveDateTime, integer (unix seconds), or nil. + """ + @spec relative_time(DateTime.t() | NaiveDateTime.t() | integer() | nil) :: String.t() + def relative_time(nil), do: "never" + + def relative_time(%DateTime{} = dt) do + relative_seconds(DateTime.diff(DateTime.utc_now(), dt)) + end + + def relative_time(%NaiveDateTime{} = ndt) do + now = NaiveDateTime.utc_now() + relative_seconds(NaiveDateTime.diff(now, ndt)) + end + + def relative_time(unix) when is_integer(unix) do + now = System.os_time(:second) + relative_seconds(now - unix) + end + + def relative_time(_), do: "—" + + defp relative_seconds(secs) when secs < 0, do: "in the future" + defp relative_seconds(secs) when secs < 60, do: "#{secs}s ago" + defp relative_seconds(secs) when secs < 3600, do: "#{div(secs, 60)}m ago" + defp relative_seconds(secs) when secs < 86_400, do: "#{div(secs, 3600)}h ago" + defp relative_seconds(secs), do: "#{div(secs, 86_400)}d ago" +end diff --git a/apps/dust_ui/lib/dust_ui/components/layouts.ex b/apps/dust_ui/lib/dust_ui/components/layouts.ex new file mode 100644 index 0000000..3f7f068 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts.ex @@ -0,0 +1,8 @@ +defmodule Dust.Ui.Layouts do + @moduledoc """ + Root and application layouts for the Dust web UI. + """ + use Dust.Ui, :html + + embed_templates "layouts/*" +end diff --git a/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex b/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex new file mode 100644 index 0000000..ec4b971 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex @@ -0,0 +1,29 @@ +
+
+
+
+ <.link navigate={~p"/"} class="text-lg font-semibold tracking-tight"> + Dust + + +
+ <.link + href={~p"/logout"} + method="delete" + class="text-sm text-zinc-500 hover:text-zinc-900" + > + Lock + +
+
+ + <.flash_group flash={@flash} /> + +
+ {@inner_content} +
+
diff --git a/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex b/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex new file mode 100644 index 0000000..7037d16 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex @@ -0,0 +1,18 @@ + + + + + + + + <.live_title default="Dust" suffix=" · Dust"> + {assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/apps/dust_ui/lib/dust_ui/components/peer_card.ex b/apps/dust_ui/lib/dust_ui/components/peer_card.ex new file mode 100644 index 0000000..187fe94 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/peer_card.ex @@ -0,0 +1,49 @@ +defmodule Dust.Ui.PeerCard do + @moduledoc false + use Phoenix.Component + + alias Dust.Ui.Format + + attr :node, :atom, required: true + attr :status, :atom, required: true + attr :seen_at, :any, default: nil + attr :fitness, :map, default: nil + + def peer_card(assigns) do + ~H""" +
+
+
+

{to_string(@node)}

+

+ Last seen {Format.relative_time(@seen_at)} +

+
+ + {to_string(@status)} + +
+ +
+
+
Success
+
{Format.percent(@fitness.success_rate, 0)}
+
+
+
Latency
+
{:erlang.float_to_binary(@fitness.latency_ms / 1.0, decimals: 0)} ms
+
+
+
Bandwidth
+
{:erlang.float_to_binary(@fitness.bandwidth / 1.0, decimals: 1)} Mb/s
+
+
+
+ """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/stat_card.ex b/apps/dust_ui/lib/dust_ui/components/stat_card.ex new file mode 100644 index 0000000..2b65609 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/stat_card.ex @@ -0,0 +1,26 @@ +defmodule Dust.Ui.StatCard do + @moduledoc false + use Phoenix.Component + + attr :title, :string, required: true + attr :value, :string, required: true + attr :subtitle, :string, default: nil + attr :href, :string, default: nil + + def stat_card(assigns) do + ~H""" +
+

{@title}

+

{@value}

+

{@subtitle}

+ <.link + :if={@href} + navigate={@href} + class="mt-3 inline-block text-sm font-medium text-zinc-700 hover:text-zinc-900" + > + View → + +
+ """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex b/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex new file mode 100644 index 0000000..9295c84 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex @@ -0,0 +1,52 @@ +defmodule Dust.Ui.DownloadController do + @moduledoc """ + Streams a file out of the Dust network into the HTTP response. + + Mirrors `Dust.Api.Handlers.FsHandler.download/2`: send chunked headers + first, then call `Dust.Daemon.FileSystem.download_stream/2` with a + `write_fn` that pipes each iodata into `Plug.Conn.chunk/2`. If decoding + fails after the first chunk is sent we cannot change status; we just + close the response and clients reject the body via size/checksum. + """ + use Dust.Ui, :controller + + def show(conn, %{"file_id" => file_id}) do + case Dust.Mesh.FileSystem.stat(file_id) do + nil -> + conn + |> put_status(:not_found) + |> text("File not found") + + meta -> + filename = Map.get(meta, :name, file_id) + mime = Map.get(meta, :mime) || "application/octet-stream" + + conn = + conn + |> put_resp_content_type(mime) + |> put_resp_header( + "content-disposition", + ~s(attachment; filename="#{sanitize(filename)}") + ) + |> send_chunked(200) + + case Dust.Daemon.FileSystem.download_stream(file_id, fn iodata -> + case Plug.Conn.chunk(conn, iodata) do + {:ok, _conn} -> :ok + {:error, _} = err -> err + end + end) do + :ok -> conn + {:error, _reason} -> conn + end + end + end + + defp sanitize(name) when is_binary(name) do + name + |> String.replace(~r/[\r\n"\\]/, "") + |> String.slice(0, 255) + end + + defp sanitize(_), do: "download" +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex b/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex new file mode 100644 index 0000000..edc412c --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex @@ -0,0 +1,101 @@ +defmodule Dust.Ui.SessionController do + @moduledoc """ + Login / logout for the Dust web UI. + + Authentication is "you know the keystore password" — the controller + delegates to `Dust.Ui.Auth.Keystore` which wraps `Dust.Core.KeyStore`. + """ + use Dust.Ui, :controller + + alias Dust.Ui.Auth.{Keystore, Session} + + def new(conn, _params) do + if first_time_setup?() do + redirect(conn, to: ~p"/setup") + else + render(conn, :new, + error: nil, + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + end + + @doc """ + Finalises a `SetupLive` flow: verifies a short-lived signed token, + marks the session authenticated, and redirects to the dashboard. + """ + def complete(conn, %{"t" => token}) do + case Phoenix.Token.verify(Dust.Ui.Endpoint, "setup_complete", token, max_age: 60) do + {:ok, true} -> + if Keystore.unlocked?() do + conn + |> Session.log_in() + |> put_flash(:info, "Welcome to Dust.") + |> redirect(to: ~p"/") + else + conn + |> put_flash(:error, "Setup did not complete. Please try again.") + |> redirect(to: ~p"/setup") + end + + _ -> + conn + |> put_flash(:error, "Setup link expired or invalid. Please try again.") + |> redirect(to: ~p"/setup") + end + end + + def complete(conn, _params), + do: conn |> put_flash(:error, "Missing token.") |> redirect(to: ~p"/setup") + + defp first_time_setup?, + do: not File.exists?(Dust.Utilities.File.master_key_file()) + + def create(conn, %{"password" => password}) do + case Keystore.unlock(password) do + :ok -> + conn + |> Session.log_in() + |> put_flash(:info, "Welcome back.") + |> redirect(to: ~p"/") + + {:error, :invalid_password} -> + conn + |> put_flash(:error, "Invalid password.") + |> render(:new, + error: "Invalid password.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + + {:error, reason} -> + conn + |> put_flash(:error, "Could not unlock keystore: #{inspect(reason)}.") + |> render(:new, + error: "Could not unlock keystore.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + end + + def create(conn, _params) do + conn + |> put_flash(:error, "Password is required.") + |> render(:new, + error: "Password is required.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + + def delete(conn, _params) do + _ = Keystore.lock() + + conn + |> Session.log_out() + |> put_flash(:info, "Locked.") + |> redirect(to: ~p"/login") + end +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_html.ex b/apps/dust_ui/lib/dust_ui/controllers/session_html.ex new file mode 100644 index 0000000..74ff875 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_html.ex @@ -0,0 +1,6 @@ +defmodule Dust.Ui.SessionHTML do + @moduledoc false + use Dust.Ui, :html + + embed_templates "session_html/*" +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex b/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex new file mode 100644 index 0000000..2f78a5b --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex @@ -0,0 +1,40 @@ +
+
+
+

Dust

+

Unlock your node to continue.

+
+ + <%= if @system_ready? do %> + <.form + for={%{}} + as={:session} + action={~p"/login"} + method="post" + class="space-y-4" + > + <.input + name="password" + id="password" + type="password" + label="Keystore password" + required + autocomplete="current-password" + placeholder="••••••••" + /> + +

{@error}

+ + <.button type="submit" class="w-full">Unlock + + <% else %> +
+

Daemon is still starting…

+

+ Waiting for the network to be ready. This page will reload shortly. +

+ +
+ <% end %> +
+
diff --git a/apps/dust_ui/lib/dust_ui/endpoint.ex b/apps/dust_ui/lib/dust_ui/endpoint.ex new file mode 100644 index 0000000..47c6418 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/endpoint.ex @@ -0,0 +1,41 @@ +defmodule Dust.Ui.Endpoint do + use Phoenix.Endpoint, otp_app: :dust_ui + + @session_options [ + store: :cookie, + key: "_dust_ui_key", + signing_salt: "dust_ui_sess", + same_site: "Lax", + max_age: 60 * 60 * 24 + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :dust_ui, + gzip: false, + only: Dust.Ui.static_paths() + + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + + plug Dust.Ui.Router +end diff --git a/apps/dust_ui/lib/dust_ui/live/files_live.ex b/apps/dust_ui/lib/dust_ui/live/files_live.ex new file mode 100644 index 0000000..dceae49 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/files_live.ex @@ -0,0 +1,458 @@ +defmodule Dust.Ui.FilesLive do + @moduledoc """ + File browser. Lists a directory, supports mkdir / rename / rm and + drag-drop upload via `Dust.Daemon.FileSystem.upload/3`. Subscribes to + upload / download progress topics on `Dust.Daemon.Registry`. + """ + use Dust.Ui, :live_view + + import Dust.Ui.FileTable + + alias Dust.Mesh.FileSystem, as: FS + + @max_upload_size 10 * 1024 * 1024 * 1024 + @max_entries 20 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Dust.Daemon.FileSystem.subscribe_upload_progress() + _ = Dust.Daemon.FileSystem.subscribe_download_progress() + end + + socket = + socket + |> assign( + page_title: "Files", + modal: nil, + rename_target: nil, + move_target: nil, + move_candidates: [], + progress: %{}, + flash_error: nil + ) + |> allow_upload(:files, + accept: :any, + max_entries: @max_entries, + max_file_size: @max_upload_size, + auto_upload: false + ) + + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + case resolve_dir_id(params["dir_id"]) do + {:ok, dir_id} -> + {:noreply, socket |> assign(current_dir_id: dir_id) |> refresh_listing()} + + {:error, :no_root} -> + {:noreply, + assign(socket, + current_dir_id: nil, + entries: %{dirs: [], files: []}, + trail: [], + needs_root?: true + )} + end + end + + # ── Events ──────────────────────────────────────────────────────────── + + @impl true + def handle_event("upload_validate", _params, socket), do: {:noreply, socket} + + def handle_event("upload_submit", _params, socket) do + dir_id = socket.assigns.current_dir_id + + consumed = + consume_uploaded_entries(socket, :files, fn %{path: path}, entry -> + case Dust.Daemon.FileSystem.upload(path, dir_id, entry.client_name) do + {:ok, file_uuid} -> {:ok, file_uuid} + {:error, reason} -> {:postpone, reason} + end + end) + + socket = + cond do + consumed == [] -> + socket + + Enum.all?(consumed, &is_binary/1) -> + socket |> put_flash(:info, "Uploaded #{length(consumed)} file(s).") |> refresh_listing() + + true -> + put_flash(socket, :error, "One or more uploads failed.") |> refresh_listing() + end + + {:noreply, socket} + end + + def handle_event("open_mkdir", _params, socket), + do: {:noreply, assign(socket, modal: :mkdir)} + + def handle_event("close_modal", _params, socket), + do: + {:noreply, + assign(socket, modal: nil, rename_target: nil, move_target: nil, move_candidates: [])} + + def handle_event("mkdir_submit", %{"name" => name}, socket) do + case FS.mkdir(socket.assigns.current_dir_id, String.trim(name)) do + {:ok, _id} -> + {:noreply, + socket |> assign(modal: nil) |> put_flash(:info, "Created.") |> refresh_listing()} + + {:error, :already_exists} -> + {:noreply, put_flash(socket, :error, "A directory with that name already exists.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "mkdir failed: #{inspect(reason)}")} + end + end + + def handle_event( + "open_rename", + %{"id" => id, "type" => type, "name" => name}, + socket + ) do + {:noreply, + assign(socket, modal: :rename, rename_target: %{id: id, type: type, current_name: name})} + end + + def handle_event("rename_submit", %{"name" => new_name}, socket) do + target = socket.assigns.rename_target + new_name = String.trim(new_name) + dir_id = socket.assigns.current_dir_id + + result = + case target.type do + "file" -> FS.move_file(target.id, dir_id, new_name) + "dir" -> FS.move_dir(target.id, dir_id, new_name) + end + + case result do + :ok -> + {:noreply, + socket + |> assign(modal: nil, rename_target: nil) + |> put_flash(:info, "Renamed.") + |> refresh_listing()} + + {:error, :name_conflict} -> + {:noreply, put_flash(socket, :error, "That name is already taken.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Rename failed: #{inspect(reason)}")} + end + end + + def handle_event( + "open_move", + %{"id" => id, "type" => type, "name" => name}, + socket + ) do + target = %{id: id, type: type, name: name} + candidates = move_candidates(target, socket.assigns.current_dir_id) + + {:noreply, assign(socket, modal: :move, move_target: target, move_candidates: candidates)} + end + + def handle_event("move_submit", %{"dest" => dest_id}, socket) do + target = socket.assigns.move_target + + result = + case target.type do + "file" -> FS.move_file(target.id, dest_id, target.name) + "dir" -> FS.move_dir(target.id, dest_id, target.name) + end + + case result do + :ok -> + {:noreply, + socket + |> assign(modal: nil, move_target: nil, move_candidates: []) + |> put_flash(:info, "Moved.") + |> refresh_listing()} + + {:error, :name_conflict} -> + {:noreply, + put_flash(socket, :error, "A #{target.type} with that name already exists there.")} + + {:error, :cycle} -> + {:noreply, put_flash(socket, :error, "Cannot move a directory into itself.")} + + {:error, :cannot_move_root} -> + {:noreply, put_flash(socket, :error, "Cannot move the root directory.")} + + {:error, :dest_not_found} -> + {:noreply, put_flash(socket, :error, "Destination no longer exists.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Move failed: #{inspect(reason)}")} + end + end + + def handle_event("rm", %{"id" => id, "type" => "file"}, socket) do + case FS.rm_file(id) do + :ok -> {:noreply, refresh_listing(socket) |> put_flash(:info, "File deleted.")} + {:error, reason} -> {:noreply, put_flash(socket, :error, "Delete failed: #{inspect(reason)}")} + end + end + + def handle_event("rm", %{"id" => id, "type" => "dir"}, socket) do + case FS.rmdir(id) do + :ok -> + {:noreply, refresh_listing(socket) |> put_flash(:info, "Directory deleted.")} + + {:error, :not_empty} -> + {:noreply, put_flash(socket, :error, "Directory is not empty.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Delete failed: #{inspect(reason)}")} + end + end + + def handle_event("init_root", _params, socket) do + case FS.mkdir(nil, "root") do + {:ok, root_id} -> + _ = Dust.Utilities.Config.put(:root_dir_id, root_id) + + {:noreply, + push_patch(socket, to: ~p"/files/#{root_id}") |> put_flash(:info, "Root created.")} + + {:error, :root_already_exists} -> + # Another node already created root — find it and navigate there. + case existing_root() do + {:ok, id} -> {:noreply, push_patch(socket, to: ~p"/files/#{id}")} + _ -> {:noreply, put_flash(socket, :error, "Could not locate existing root.")} + end + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Init failed: #{inspect(reason)}")} + end + end + + # ── Progress messages from Dust.Daemon.FileSystem PubSub ────────────── + + @impl true + def handle_info({:upload_progress, file_uuid, chunk, total}, socket) do + {:noreply, update_progress(socket, file_uuid, :upload, chunk, total)} + end + + def handle_info({:download_progress, file_uuid, chunk, total}, socket) do + {:noreply, update_progress(socket, file_uuid, :download, chunk, total)} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + # ── Render ──────────────────────────────────────────────────────────── + + @impl true + def render(assigns) do + ~H""" +
+ <%= if assigns[:needs_root?] do %> +
+

No root directory yet

+

+ Initialize the filesystem to start uploading files. +

+ <.button phx-click="init_root" class="mt-4">Create root directory +
+ <% else %> +
+ <.breadcrumbs trail={@trail} /> + <.button phx-click="open_mkdir">New folder +
+ + <.upload_dropzone uploads={@uploads} /> + + <.file_table dirs={@entries.dirs} files={@entries.files} progress={@progress} /> + <% end %> + + <.modal :if={@modal == :mkdir} on_close="close_modal" title="New folder"> +
+ <.input name="name" id="mkdir-name" label="Name" required autocomplete="off" /> +
+ + <.button type="submit">Create +
+
+ + + <.modal :if={@modal == :move} on_close="close_modal" title={"Move #{@move_target.type}"}> +

+ Move {@move_target.name} to: +

+ <%= if @move_candidates == [] do %> +

No eligible destinations.

+ <% else %> +
    +
  • + +
  • +
+ <% end %> +
+ +
+ + + <.modal :if={@modal == :rename} on_close="close_modal" title={"Rename #{@rename_target.type}"}> +
+ <.input + name="name" + id="rename-name" + label="New name" + value={@rename_target.current_name} + required + autocomplete="off" + /> +
+ + <.button type="submit">Rename +
+
+ +
+ """ + end + + # ── Helpers ─────────────────────────────────────────────────────────── + + defp resolve_dir_id(nil) do + case existing_root() do + {:ok, id} -> {:ok, id} + :error -> {:error, :no_root} + end + end + + defp resolve_dir_id(""), do: resolve_dir_id(nil) + defp resolve_dir_id(id) when is_binary(id), do: {:ok, id} + + defp existing_root do + case String.trim(Dust.Utilities.Config.root_dir_id()) do + "" -> + find_root_in_crdt() + + id -> + if FS.get_dir(id), do: {:ok, id}, else: find_root_in_crdt() + end + end + + defp find_root_in_crdt do + case Enum.find(FS.all_dirs(), fn {_id, dir} -> Map.get(dir, :parent_id) == nil end) do + {id, _dir} -> + # Best-effort cache; fine if it fails (e.g. already set, no permission). + _ = Dust.Utilities.Config.put(:root_dir_id, id) + {:ok, id} + + _ -> + :error + end + end + + defp refresh_listing(socket) do + dir_id = socket.assigns.current_dir_id + + case dir_id && FS.ls(dir_id) do + %{dirs: dirs, files: files} -> + assign(socket, + entries: %{dirs: dirs, files: files}, + trail: build_trail(dir_id), + needs_root?: false + ) + + _ -> + assign(socket, entries: %{dirs: [], files: []}, trail: build_trail(dir_id), needs_root?: false) + end + end + + defp build_trail(nil), do: [] + + defp build_trail(dir_id) do + walk_up(dir_id, []) + end + + defp walk_up(nil, acc), do: acc + + defp walk_up(id, acc) when is_binary(id) do + case FS.get_dir(id) do + nil -> + acc + + dir -> + crumb = %{id: id, name: Map.get(dir, :name) || "root"} + walk_up(Map.get(dir, :parent_id), [crumb | acc]) + end + end + + # Returns destination directories the user may move `target` into. + # + # Always excludes the current parent (no-op move). For a directory target + # also excludes the directory itself and every descendant to prevent + # cycles. Each returned entry has `:id` and `:path` ("root / a / b"). + defp move_candidates(target, current_dir_id) do + dirs = FS.all_dirs() + excluded = excluded_ids(target, dirs) |> MapSet.put(current_dir_id) + + dirs + |> Enum.reject(fn {id, _} -> MapSet.member?(excluded, id) end) + |> Enum.map(fn {id, _} -> %{id: id, path: dir_path(id, dirs)} end) + |> Enum.sort_by(& &1.path) + end + + defp excluded_ids(%{type: "dir", id: dir_id}, dirs) do + descendants(dir_id, dirs) |> MapSet.put(dir_id) + end + + defp excluded_ids(_target, _dirs), do: MapSet.new() + + defp descendants(dir_id, dirs) do + children = + for {id, entry} <- dirs, Map.get(entry, :parent_id) == dir_id, into: MapSet.new(), do: id + + Enum.reduce(children, children, fn child, acc -> + MapSet.union(acc, descendants(child, dirs)) + end) + end + + defp dir_path(id, dirs), do: dir_path(id, dirs, []) + + defp dir_path(nil, _dirs, acc), do: Enum.join(acc, " / ") + + defp dir_path(id, dirs, acc) do + case Map.get(dirs, id) do + nil -> Enum.join(acc, " / ") + entry -> dir_path(Map.get(entry, :parent_id), dirs, [Map.get(entry, :name) || "root" | acc]) + end + end + + defp update_progress(socket, file_uuid, kind, chunk, total) do + entry = %{kind: kind, chunk: chunk, total: total} + + progress = + if chunk >= total do + Map.delete(socket.assigns.progress, file_uuid) + else + Map.put(socket.assigns.progress, file_uuid, entry) + end + + assign(socket, progress: progress) + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/index_live.ex b/apps/dust_ui/lib/dust_ui/live/index_live.ex new file mode 100644 index 0000000..c939995 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/index_live.ex @@ -0,0 +1,100 @@ +defmodule Dust.Ui.IndexLive do + @moduledoc """ + Top-level dashboard. Three stat cards (files / peers / storage) plus a + system-readiness banner. + """ + use Dust.Ui, :live_view + + import Dust.Ui.StatCard + + alias Dust.Ui.Format + + @refresh_ms 5_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Registry.register(Dust.Daemon.Registry, :system_ready, []) + _ = Dust.Mesh.NodeRegistry.subscribe() + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Dashboard") |> assign_metrics()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_metrics(socket)} + end + + def handle_info({:system_ready, _node}, socket), do: {:noreply, assign_metrics(socket)} + + def handle_info({:node_registry_changes, _state}, socket), + do: {:noreply, assign_metrics(socket)} + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+ Daemon is still bootstrapping. Some statistics may be unavailable. +
+ +

Overview

+ +
+ <.stat_card + title="Files" + value={Integer.to_string(@files_count)} + subtitle={"#{@dirs_count} directories"} + href={~p"/files"} + /> + <.stat_card + title="Peers online" + value={Integer.to_string(@online_peer_count)} + subtitle={"#{@total_peer_count} known"} + href={~p"/peers"} + /> + <.stat_card + title="Storage" + value={Format.bytes(@used_bytes)} + subtitle={"of #{Format.bytes(@quota_bytes)} quota"} + href={~p"/storage"} + /> +
+
+ """ + end + + defp assign_metrics(socket) do + system_ready? = Dust.Daemon.Readiness.ready?() + registry = safe(fn -> Dust.Mesh.NodeRegistry.list() end, %{}) + online = safe(fn -> Dust.Mesh.NodeRegistry.online_nodes() end, []) + files_count = safe(fn -> Dust.Mesh.FileSystem.all_files() |> map_size() end, 0) + dirs_count = safe(fn -> Dust.Mesh.FileSystem.all_dirs() |> map_size() end, 0) + quota_bytes = safe(fn -> Dust.Daemon.DiskManager.get_quota() end, 0) + used_bytes = safe(fn -> Dust.Daemon.DiskManager.usage_bytes() end, 0) + + assign(socket, + system_ready?: system_ready?, + files_count: files_count, + dirs_count: dirs_count, + online_peer_count: length(online), + total_peer_count: map_size(registry), + used_bytes: used_bytes, + quota_bytes: quota_bytes + ) + end + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/peers_live.ex b/apps/dust_ui/lib/dust_ui/live/peers_live.ex new file mode 100644 index 0000000..73526cf --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/peers_live.ex @@ -0,0 +1,111 @@ +defmodule Dust.Ui.PeersLive do + @moduledoc """ + Live dashboard of peer status and Tailscale bridge state. + """ + use Dust.Ui, :live_view + + import Dust.Ui.PeerCard + + @refresh_ms 10_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Dust.Mesh.NodeRegistry.subscribe() + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Peers") |> assign_state()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_state(socket)} + end + + def handle_info({:node_registry_changes, _state}, socket), + do: {:noreply, assign_state(socket)} + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+

Peers

+

Cluster membership and Tailscale bridge state.

+
+ +
+

Tailscale

+

+ State: {@bridge.state} +

+

+ IP: + {@bridge.self_ip} +

+

+ + Open auth URL + +

+

+ Bridge error: {@bridge.error} +

+
+ +
+ No peers known yet. +
+ +
+ <.peer_card + :for={p <- @peers} + node={p.node} + status={p.status} + seen_at={p.seen_at} + fitness={p.fitness} + /> +
+
+ """ + end + + defp assign_state(socket) do + bridge = + case safe(fn -> Dust.Bridge.auth_status() end, :error) do + {:ok, info} -> Map.merge(%{state: "unknown", self_ip: nil, auth_url: nil, error: nil}, info) + {:error, reason} -> %{state: "error", self_ip: nil, auth_url: nil, error: inspect(reason)} + :error -> %{state: "unavailable", self_ip: nil, auth_url: nil, error: nil} + end + + registry = safe(fn -> Dust.Mesh.NodeRegistry.list() end, %{}) + fitness_by_node = safe(fn -> Dust.Core.Fitness.list() end, []) |> Map.new() + + peers = + registry + |> Enum.map(fn {node, info} -> + %{ + node: node, + status: Map.get(info, :status, :unknown), + seen_at: Map.get(info, :seen_at), + fitness: Map.get(fitness_by_node, node) + } + end) + |> Enum.sort_by(fn p -> {p.status != :online, to_string(p.node)} end) + + assign(socket, bridge: bridge, peers: peers) + end + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/setup_live.ex b/apps/dust_ui/lib/dust_ui/live/setup_live.ex new file mode 100644 index 0000000..65d3791 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/setup_live.ex @@ -0,0 +1,470 @@ +defmodule Dust.Ui.SetupLive do + @moduledoc """ + First-time setup wizard. + + Mirrors `dustctl init`: pick a device name, set a keystore password, + bring up Tailscale with the chosen name (NOT before — the bridge + defers its sidecar startup until init completes), then create a new + network or join an existing one. + + Visited only when `Dust.Utilities.File.master_key_file/0` does not yet + exist; `SessionController.new` redirects here. After a successful + submission the LiveView mints a short-lived `Phoenix.Token` and + navigates to `GET /setup/complete?t=...`, which actually sets the + session cookie (LiveView sockets can't write to `Plug.Session`). + """ + use Dust.Ui, :live_view + + @node_name_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9_-]{0,62}\z/ + + @impl true + def mount(_params, _session, socket) do + if first_time_setup?() do + {:ok, + assign(socket, + page_title: "First-time setup", + step: :choose, + mode: nil, + password: "", + password_confirm: "", + node_name: "dust", + peer_ip: "", + token: "", + error: nil, + busy?: false, + tailscale: %{state: "unavailable", self_ip: nil, auth_url: nil} + )} + else + {:ok, redirect(socket, to: ~p"/login")} + end + end + + defp first_time_setup?, + do: not File.exists?(Dust.Utilities.File.master_key_file()) + + # ── Step 1: Choose Create vs Join ─────────────────────────────────── + + @impl true + def handle_event("choose_mode", %{"mode" => mode}, socket) when mode in ["create", "join"] do + {:noreply, assign(socket, mode: String.to_atom(mode), step: :details, error: nil)} + end + + def handle_event("back", _params, socket), + do: {:noreply, assign(socket, step: :choose, error: nil)} + + # ── Step 2: Submit details ────────────────────────────────────────── + + def handle_event("submit", params, socket) do + socket = + assign(socket, + password: Map.get(params, "password", ""), + password_confirm: Map.get(params, "password_confirm", ""), + node_name: Map.get(params, "node_name", "") |> String.trim(), + peer_ip: Map.get(params, "peer_ip", "") |> String.trim(), + token: Map.get(params, "token", "") |> String.trim() + ) + + case validate(socket.assigns) do + :ok -> + {:noreply, + socket + |> assign(busy?: true, error: nil, step: :provisioning) + |> tap_send(:provision)} + + {:error, message} -> + {:noreply, assign(socket, error: message)} + end + end + + # ── Provisioning state machine ────────────────────────────────────── + + @impl true + def handle_info(:provision, socket) do + a = socket.assigns + + with :ok <- save_node_name(a.node_name), + :ok <- unlock_keystore(a.password), + :ok <- start_sidecar() do + case a.mode do + :create -> + # Tailscale is starting in the background, but we don't need it + # to be authenticated to create the root directory — the genesis + # node can finish setup offline and authenticate later. + finish_create(socket) + + :join -> + # Have to wait for Tailscale to come up and authenticate before + # we can do the actual cluster join handshake. + {:noreply, + socket + |> assign(step: :await_tailscale, tailscale: fetch_bridge_status()) + |> tap_send_after(:poll_tailscale, 1_500)} + end + else + {:error, message} -> + {:noreply, assign(socket, busy?: false, error: message, step: :details)} + end + end + + def handle_info(:poll_tailscale, socket) do + ts = fetch_bridge_status() + + if ts_ready?(ts) do + finish_join(assign(socket, tailscale: ts)) + else + {:noreply, + socket + |> assign(tailscale: ts) + |> tap_send_after(:poll_tailscale, 2_000)} + end + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + defp finish_create(socket) do + case create_root() do + :ok -> push_navigate(socket, to: ~p"/setup/complete?t=#{setup_token()}") + {:error, msg} -> {:noreply, assign(socket, busy?: false, error: msg, step: :details)} + end + |> wrap_noreply() + end + + defp finish_join(socket) do + bridge = Application.get_env(:dust_bridge, :bridge_module, Dust.Bridge) + + case bridge.join(socket.assigns.peer_ip, socket.assigns.token) do + {:ok, _master_key, _otp_cookie} -> + {:noreply, push_navigate(socket, to: ~p"/setup/complete?t=#{setup_token()}")} + + {:error, reason} -> + {:noreply, + assign(socket, + busy?: false, + error: "Could not join network: #{inspect(reason)}", + step: :details + )} + end + end + + defp wrap_noreply({:noreply, _socket} = result), do: result + defp wrap_noreply(socket), do: {:noreply, socket} + + # ── Render ────────────────────────────────────────────────────────── + + @impl true + def render(assigns) do + ~H""" +
+
+
+

Welcome to Dust

+

Let's get your node set up.

+
+ + <%= case @step do %> + <% :choose -> %> + <.choose_mode /> + <% :details -> %> + <.details + mode={@mode} + password={@password} + password_confirm={@password_confirm} + node_name={@node_name} + peer_ip={@peer_ip} + token={@token} + error={@error} + busy?={@busy?} + /> + <% :provisioning -> %> + <.progress_card title="Setting up…"> +

Saving device name, unlocking keystore, and starting Tailscale.

+ + <% :await_tailscale -> %> + <.await_tailscale_card tailscale={@tailscale} /> + <% end %> +
+
+ """ + end + + defp choose_mode(assigns) do + ~H""" +
+ + + +
+ """ + end + + defp details(assigns) do + ~H""" +
+

+ <%= if @mode == :create do %> + Set a password and name this device. Tailscale will start once setup begins. + <% else %> + Set a password, name this device, and enter the invite from another node. + <% end %> +

+ + <.input + name="node_name" + id="node_name" + label="Device name" + value={@node_name} + required + autocomplete="off" + placeholder="e.g. laptop" + /> + + <.input + name="password" + id="password" + type="password" + label="Keystore password" + value={@password} + required + autocomplete="new-password" + /> + + <.input + name="password_confirm" + id="password_confirm" + type="password" + label="Confirm password" + value={@password_confirm} + required + autocomplete="new-password" + /> + + <%= if @mode == :join do %> + <.input + name="peer_ip" + id="peer_ip" + label="Peer Tailscale IP" + value={@peer_ip} + required + autocomplete="off" + placeholder="100.64.0.X" + /> + <.input + name="token" + id="token" + label="Invite token" + value={@token} + required + autocomplete="off" + /> + <% end %> + +

{@error}

+ +
+ + + <.button type="submit" {%{disabled: @busy?}}> + {if @busy?, do: "Setting up…", else: submit_label(@mode)} + +
+
+ """ + end + + defp progress_card(assigns) do + ~H""" +
+

{@title}

+
{render_slot(@inner_block)}
+
+ """ + end + + attr :tailscale, :map, required: true + slot :inner_block + + defp await_tailscale_card(assigns) do + ~H""" +
+
+
+

Waiting for Tailscale

+

+ {ts_label(@tailscale)} +

+
+ waiting +
+ +
+

Open this URL on any device to authenticate this node:

+ + {@tailscale.auth_url} + +
+ +

+ Once Tailscale is authenticated, this page will continue automatically. +

+
+ """ + end + + defp submit_label(:create), do: "Create network" + defp submit_label(:join), do: "Join network" + + defp ts_ready?(%{state: "authenticated", self_ip: ip}) when is_binary(ip) and ip != "", + do: true + + defp ts_ready?(_), do: false + + defp ts_label(%{state: "authenticated"}), do: "Connected" + defp ts_label(%{state: "needs_auth"}), do: "Needs authentication" + defp ts_label(%{state: "unavailable"}), do: "Starting…" + defp ts_label(%{state: state}), do: state || "Unknown" + + # ── Validation ────────────────────────────────────────────────────── + + defp validate(assigns) do + cond do + assigns.password == "" -> + {:error, "Password is required."} + + byte_size(assigns.password) < 8 -> + {:error, "Password must be at least 8 characters."} + + assigns.password != assigns.password_confirm -> + {:error, "Passwords do not match."} + + assigns.node_name == "" -> + {:error, "Device name is required."} + + not Regex.match?(@node_name_pattern, assigns.node_name) -> + {:error, + "Device name must start with a letter or digit and use only letters, digits, '-', or '_'."} + + assigns.mode == :join and assigns.peer_ip == "" -> + {:error, "Peer IP is required to join."} + + assigns.mode == :join and assigns.token == "" -> + {:error, "Invite token is required to join."} + + true -> + :ok + end + end + + # ── Provision helpers ─────────────────────────────────────────────── + + defp save_node_name(name) do + case Dust.Utilities.Config.put(:node_name, name) do + :ok -> :ok + {:error, reason} -> {:error, "Could not save device name: #{inspect(reason)}"} + end + end + + defp unlock_keystore(password) do + case Dust.Core.KeyStore.unlock(password) do + :ok -> + :ok + + {:error, :already_unlocked} -> + :ok + + {:error, :decrypt_failed} -> + {:error, "Keystore is already initialized with a different password."} + + {:error, reason} -> + {:error, "Could not unlock keystore: #{inspect(reason)}"} + end + end + + defp start_sidecar do + case Dust.Bridge.start_sidecar() do + :ok -> :ok + {:error, reason} -> {:error, "Could not start Tailscale: #{inspect(reason)}"} + end + end + + defp create_root do + case Dust.Mesh.FileSystem.mkdir(nil, "/") do + {:ok, root_id} -> + _ = Dust.Utilities.Config.put(:root_dir_id, root_id) + :ok + + {:error, :root_already_exists} -> + case Enum.find(Dust.Mesh.FileSystem.all_dirs(), fn {_id, d} -> + Map.get(d, :parent_id) == nil + end) do + {id, _} -> + _ = Dust.Utilities.Config.put(:root_dir_id, id) + :ok + + _ -> + :ok + end + + {:error, reason} -> + {:error, "Could not create root directory: #{inspect(reason)}"} + end + end + + defp fetch_bridge_status do + bridge = Application.get_env(:dust_bridge, :bridge_module, Dust.Bridge) + + try do + case bridge.auth_status() do + {:ok, info} -> Map.merge(%{state: "unknown", self_ip: nil, auth_url: nil}, info) + _ -> %{state: "unavailable", self_ip: nil, auth_url: nil} + end + rescue + _ -> %{state: "unavailable", self_ip: nil, auth_url: nil} + catch + :exit, _ -> %{state: "unavailable", self_ip: nil, auth_url: nil} + end + end + + defp setup_token, + do: Phoenix.Token.sign(Dust.Ui.Endpoint, "setup_complete", true, max_age: 60) + + defp tap_send(socket, msg) do + send(self(), msg) + socket + end + + defp tap_send_after(socket, msg, ms) do + Process.send_after(self(), msg, ms) + socket + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/storage_live.ex b/apps/dust_ui/lib/dust_ui/live/storage_live.ex new file mode 100644 index 0000000..eb979a0 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/storage_live.ex @@ -0,0 +1,149 @@ +defmodule Dust.Ui.StorageLive do + @moduledoc """ + Storage health dashboard. Disk usage vs quota, GC and Repair scheduler + stats, and a replication-health count. + """ + use Dust.Ui, :live_view + + import Dust.Ui.StatCard + + alias Dust.Ui.Format + + @refresh_ms 10_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Storage") |> assign_state()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_state(socket)} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+

Storage

+

Local shard usage and background-sweep health.

+
+ +
+
+
+

Local usage

+

{Format.bytes(@used_bytes)}

+
+

of {Format.bytes(@quota_bytes)}

+
+
+
+
+

+ {@shard_count} shards · replication factor {@replication_factor} +

+
+ +
+ <.stat_card + title="Garbage collector" + value={Integer.to_string(@gc.last_orphans_removed + @gc.last_replicas_removed)} + subtitle={"Last sweep #{Format.relative_time(@gc.last_sweep_at)} · #{@gc.last_orphans_removed} orphans, #{@gc.last_replicas_removed} replicas"} + /> + <.stat_card + title="Repair scheduler" + value={Integer.to_string(@repair.shards_cloned + @repair.shards_reconstructed)} + subtitle={"Last sweep #{Format.relative_time(@repair.last_sweep_at)} · #{@repair.shards_cloned} cloned, #{@repair.shards_reconstructed} reconstructed"} + /> + <.stat_card + title="Integrity issues (last sweep)" + value={Integer.to_string(@repair.integrity_removed)} + subtitle={"Stale manifest entries: #{@repair.stale_entries_cleaned}"} + /> + <.stat_card + title="Under-replicated chunks" + value={Integer.to_string(@under_replicated)} + subtitle="Online holders below replication factor" + /> +
+
+ """ + end + + defp assign_state(socket) do + quota_bytes = safe(fn -> Dust.Daemon.DiskManager.get_quota() end, 0) + used_bytes = safe(fn -> Dust.Daemon.DiskManager.usage_bytes() end, 0) + replication_factor = safe(fn -> Dust.Utilities.Config.replication_factor() end, 1) + online = safe(fn -> Dust.Mesh.NodeRegistry.online_nodes() end, []) |> MapSet.new() + gc = safe(fn -> Dust.Daemon.GarbageCollector.stats() end, default_gc()) + repair = safe(fn -> Dust.Daemon.RepairScheduler.stats() end, default_repair()) + + {shard_count, under_replicated} = + safe( + fn -> shard_stats(Dust.Mesh.Manifest.ShardMap.all_grouped(), online, replication_factor) end, + {0, 0} + ) + + assign(socket, + quota_bytes: quota_bytes, + used_bytes: used_bytes, + replication_factor: replication_factor, + shard_count: shard_count, + under_replicated: under_replicated, + gc: Map.merge(default_gc(), gc), + repair: Map.merge(default_repair(), repair) + ) + end + + defp shard_stats(grouped, online, replication_factor) when is_map(grouped) do + Enum.reduce(grouped, {0, 0}, fn {_chunk_hash, shards}, {count, under} -> + online_holders = + shards + |> Enum.flat_map(fn {_idx, %{nodes: nodes}} -> MapSet.to_list(nodes) end) + |> MapSet.new() + |> MapSet.intersection(online) + |> MapSet.size() + + delta_under = if online_holders < replication_factor, do: 1, else: 0 + {count + 1, under + delta_under} + end) + end + + defp shard_stats(_, _, _), do: {0, 0} + + defp quota_percent(_used, 0), do: 0 + + defp quota_percent(used, quota) when is_integer(quota) and quota > 0, + do: trunc(used * 100 / quota) |> min(100) |> max(0) + + defp quota_percent(_, _), do: 0 + + defp default_gc, do: %{last_sweep_at: nil, last_orphans_removed: 0, last_replicas_removed: 0} + + defp default_repair, + do: %{ + last_sweep_at: nil, + integrity_removed: 0, + shards_cloned: 0, + shards_reconstructed: 0, + stale_entries_cleaned: 0 + } + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/router.ex b/apps/dust_ui/lib/dust_ui/router.ex new file mode 100644 index 0000000..faeccfb --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/router.ex @@ -0,0 +1,49 @@ +defmodule Dust.Ui.Router do + use Dust.Ui, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {Dust.Ui.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :require_auth do + plug Dust.Ui.Auth.Session, :require_authenticated + end + + pipeline :redirect_if_authed do + plug Dust.Ui.Auth.Session, :redirect_if_authenticated + end + + scope "/", Dust.Ui do + pipe_through :browser + + scope "/" do + pipe_through :redirect_if_authed + get "/login", SessionController, :new + post "/login", SessionController, :create + + live "/setup", SetupLive, :new + get "/setup/complete", SessionController, :complete + end + + delete "/logout", SessionController, :delete + + scope "/" do + pipe_through :require_auth + + live_session :authenticated, on_mount: [Dust.Ui.Auth.LiveAuth] do + live "/", IndexLive, :show + live "/files", FilesLive, :root + live "/files/:dir_id", FilesLive, :show + live "/peers", PeersLive, :show + live "/storage", StorageLive, :show + end + + get "/download/:file_id", DownloadController, :show + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/telemetry.ex b/apps/dust_ui/lib/dust_ui/telemetry.ex new file mode 100644 index 0000000..e2b1a82 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/telemetry.ex @@ -0,0 +1,50 @@ +defmodule Dust.Ui.Telemetry do + @moduledoc false + + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + summary("phoenix.endpoint.start.system_time", unit: {:native, :millisecond}), + summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", unit: {:native, :millisecond}), + summary("phoenix.channel_joined.duration", unit: {:native, :millisecond}), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements, do: [] +end diff --git a/apps/dust_ui/mix.exs b/apps/dust_ui/mix.exs index c6d1dad..e6a58cf 100644 --- a/apps/dust_ui/mix.exs +++ b/apps/dust_ui/mix.exs @@ -11,23 +11,50 @@ defmodule Dust.Ui.MixProject do lockfile: "../../mix.lock", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, + aliases: aliases(), deps: deps() ] end - # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger] + extra_applications: [:logger, :runtime_tools], + mod: {Dust.Ui.Application, []} ] end - # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, - # {:sibling_app_in_umbrella, in_umbrella: true} + {:phoenix, "~> 1.8"}, + {:phoenix_live_view, "~> 1.0"}, + {:phoenix_live_reload, "~> 1.5", only: :dev}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_pubsub, "~> 2.1"}, + {:bandit, "~> 1.6"}, + {:plug, "~> 1.16"}, + {:jason, "~> 1.4"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.1"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:floki, ">= 0.30.0", only: :test}, + {:dust_daemon, in_umbrella: true}, + {:dust_core, in_umbrella: true}, + {:dust_mesh, in_umbrella: true}, + {:dust_bridge, in_umbrella: true}, + {:dust_utilities, in_umbrella: true} + ] + end + + defp aliases do + [ + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind dust_ui", "esbuild dust_ui"], + "assets.deploy": [ + "tailwind dust_ui --minify", + "esbuild dust_ui --minify", + "phx.digest" + ] ] end end diff --git a/apps/dust_ui/priv/static/assets/app.css b/apps/dust_ui/priv/static/assets/app.css new file mode 100644 index 0000000..aa9a4d4 --- /dev/null +++ b/apps/dust_ui/priv/static/assets/app.css @@ -0,0 +1,1363 @@ +/* +! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.inset-0 { + inset: 0px; +} + +.right-4 { + right: 1rem; +} + +.top-4 { + top: 1rem; +} + +.z-10 { + z-index: 10; +} + +.z-40 { + z-index: 40; +} + +.z-50 { + z-index: 50; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-0\.5 { + margin-top: 0.125rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.grid { + display: grid; +} + +.contents { + display: contents; +} + +.h-1 { + height: 0.25rem; +} + +.h-2 { + height: 0.5rem; +} + +.h-full { + height: 100%; +} + +.max-h-80 { + max-height: 20rem; +} + +.min-h-full { + min-height: 100%; +} + +.min-h-screen { + min-height: 100vh; +} + +.w-full { + width: 100%; +} + +.min-w-full { + min-width: 100%; +} + +.max-w-6xl { + max-width: 72rem; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-sm { + max-width: 24rem; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-pointer { + cursor: pointer; +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.flex-col { + flex-direction: column; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +.items-baseline { + align-items: baseline; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-6 { + gap: 1.5rem; +} + +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} + +.space-y-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); +} + +.divide-y > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); +} + +.divide-zinc-100 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(244 244 245 / var(--tw-divide-opacity)); +} + +.divide-zinc-200 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(228 228 231 / var(--tw-divide-opacity)); +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.break-all { + word-break: break-all; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-0 { + border-width: 0px; +} + +.border-2 { + border-width: 2px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + +.border-dashed { + border-style: dashed; +} + +.border-amber-200 { + --tw-border-opacity: 1; + border-color: rgb(253 230 138 / var(--tw-border-opacity)); +} + +.border-zinc-200 { + --tw-border-opacity: 1; + border-color: rgb(228 228 231 / var(--tw-border-opacity)); +} + +.border-zinc-300 { + --tw-border-opacity: 1; + border-color: rgb(212 212 216 / var(--tw-border-opacity)); +} + +.bg-amber-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 243 199 / var(--tw-bg-opacity)); +} + +.bg-amber-200 { + --tw-bg-opacity: 1; + background-color: rgb(253 230 138 / var(--tw-bg-opacity)); +} + +.bg-amber-50 { + --tw-bg-opacity: 1; + background-color: rgb(255 251 235 / var(--tw-bg-opacity)); +} + +.bg-emerald-100 { + --tw-bg-opacity: 1; + background-color: rgb(209 250 229 / var(--tw-bg-opacity)); +} + +.bg-emerald-50 { + --tw-bg-opacity: 1; + background-color: rgb(236 253 245 / var(--tw-bg-opacity)); +} + +.bg-emerald-500 { + --tw-bg-opacity: 1; + background-color: rgb(16 185 129 / var(--tw-bg-opacity)); +} + +.bg-emerald-600 { + --tw-bg-opacity: 1; + background-color: rgb(5 150 105 / var(--tw-bg-opacity)); +} + +.bg-rose-50 { + --tw-bg-opacity: 1; + background-color: rgb(255 241 242 / var(--tw-bg-opacity)); +} + +.bg-sky-500 { + --tw-bg-opacity: 1; + background-color: rgb(14 165 233 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-zinc-100 { + --tw-bg-opacity: 1; + background-color: rgb(244 244 245 / var(--tw-bg-opacity)); +} + +.bg-zinc-50 { + --tw-bg-opacity: 1; + background-color: rgb(250 250 250 / var(--tw-bg-opacity)); +} + +.bg-zinc-700 { + --tw-bg-opacity: 1; + background-color: rgb(63 63 70 / var(--tw-bg-opacity)); +} + +.bg-zinc-900 { + --tw-bg-opacity: 1; + background-color: rgb(24 24 27 / var(--tw-bg-opacity)); +} + +.bg-zinc-900\/40 { + background-color: rgb(24 24 27 / 0.4); +} + +.p-3 { + padding: 0.75rem; +} + +.p-4 { + padding: 1rem; +} + +.p-5 { + padding: 1.25rem; +} + +.p-6 { + padding: 1.5rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-0 { + padding-top: 0px; + padding-bottom: 0px; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-3 { + padding-top: 0.75rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.leading-5 { + line-height: 1.25rem; +} + +.leading-6 { + line-height: 1.5rem; +} + +.tracking-tight { + letter-spacing: -0.025em; +} + +.tracking-wide { + letter-spacing: 0.025em; +} + +.text-amber-700 { + --tw-text-opacity: 1; + color: rgb(180 83 9 / var(--tw-text-opacity)); +} + +.text-amber-900 { + --tw-text-opacity: 1; + color: rgb(120 53 15 / var(--tw-text-opacity)); +} + +.text-emerald-700 { + --tw-text-opacity: 1; + color: rgb(4 120 87 / var(--tw-text-opacity)); +} + +.text-emerald-900 { + --tw-text-opacity: 1; + color: rgb(6 78 59 / var(--tw-text-opacity)); +} + +.text-rose-600 { + --tw-text-opacity: 1; + color: rgb(225 29 72 / var(--tw-text-opacity)); +} + +.text-rose-900 { + --tw-text-opacity: 1; + color: rgb(136 19 55 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-zinc-300 { + --tw-text-opacity: 1; + color: rgb(212 212 216 / var(--tw-text-opacity)); +} + +.text-zinc-400 { + --tw-text-opacity: 1; + color: rgb(161 161 170 / var(--tw-text-opacity)); +} + +.text-zinc-500 { + --tw-text-opacity: 1; + color: rgb(113 113 122 / var(--tw-text-opacity)); +} + +.text-zinc-600 { + --tw-text-opacity: 1; + color: rgb(82 82 91 / var(--tw-text-opacity)); +} + +.text-zinc-700 { + --tw-text-opacity: 1; + color: rgb(63 63 70 / var(--tw-text-opacity)); +} + +.text-zinc-900 { + --tw-text-opacity: 1; + color: rgb(24 24 27 / var(--tw-text-opacity)); +} + +.underline { + text-decoration-line: underline; +} + +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.opacity-0 { + opacity: 0; +} + +.opacity-100 { + opacity: 1; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + +.ring-amber-200 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(253 230 138 / var(--tw-ring-opacity)); +} + +.ring-emerald-200 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(167 243 208 / var(--tw-ring-opacity)); +} + +.ring-rose-200 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(254 205 211 / var(--tw-ring-opacity)); +} + +.ring-rose-400 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(251 113 133 / var(--tw-ring-opacity)); +} + +.ring-zinc-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(212 212 216 / var(--tw-ring-opacity)); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-200 { + transition-duration: 200ms; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +/* App-wide tweaks live here. */ + +html, +body { + background-color: rgb(250 250 250); +} + +.placeholder\:text-zinc-400::-moz-placeholder { + --tw-text-opacity: 1; + color: rgb(161 161 170 / var(--tw-text-opacity)); +} + +.placeholder\:text-zinc-400::placeholder { + --tw-text-opacity: 1; + color: rgb(161 161 170 / var(--tw-text-opacity)); +} + +.hover\:border-zinc-400:hover { + --tw-border-opacity: 1; + border-color: rgb(161 161 170 / var(--tw-border-opacity)); +} + +.hover\:bg-emerald-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(4 120 87 / var(--tw-bg-opacity)); +} + +.hover\:bg-zinc-50:hover { + --tw-bg-opacity: 1; + background-color: rgb(250 250 250 / var(--tw-bg-opacity)); +} + +.hover\:bg-zinc-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(63 63 70 / var(--tw-bg-opacity)); +} + +.hover\:text-rose-800:hover { + --tw-text-opacity: 1; + color: rgb(159 18 57 / var(--tw-text-opacity)); +} + +.hover\:text-zinc-700:hover { + --tw-text-opacity: 1; + color: rgb(63 63 70 / var(--tw-text-opacity)); +} + +.hover\:text-zinc-900:hover { + --tw-text-opacity: 1; + color: rgb(24 24 27 / var(--tw-text-opacity)); +} + +.hover\:underline:hover { + text-decoration-line: underline; +} + +.focus\:ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-inset:focus { + --tw-ring-inset: inset; +} + +.focus\:ring-rose-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(244 63 94 / var(--tw-ring-opacity)); +} + +.focus\:ring-zinc-900:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(24 24 27 / var(--tw-ring-opacity)); +} + +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + +@media (min-width: 640px) { + .sm\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .sm\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .sm\:leading-6 { + line-height: 1.5rem; + } +} + +@media (min-width: 768px) { + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/apps/dust_ui/priv/static/assets/app.js b/apps/dust_ui/priv/static/assets/app.js new file mode 100644 index 0000000..7b8742d --- /dev/null +++ b/apps/dust_ui/priv/static/assets/app.js @@ -0,0 +1,8655 @@ +(() => { + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod + )); + + // vendor/topbar.js + var require_topbar = __commonJS({ + "vendor/topbar.js"(exports, module) { + (function(window2, document2) { + "use strict"; + (function() { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window2.requestAnimationFrame; ++x) { + window2.requestAnimationFrame = window2[vendors[x] + "RequestAnimationFrame"]; + window2.cancelAnimationFrame = window2[vendors[x] + "CancelAnimationFrame"] || window2[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window2.requestAnimationFrame) + window2.requestAnimationFrame = function(callback) { + var currTime = (/* @__PURE__ */ new Date()).getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window2.setTimeout(function() { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window2.cancelAnimationFrame) + window2.cancelAnimationFrame = function(id) { + clearTimeout(id); + }; + })(); + var canvas, progressTimerId, fadeTimerId, currentProgress, showing, addEvent = function(elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, options = { + autoRun: true, + barThickness: 3, + barColors: { 0: "rgba(26, 188, 156, .9)", ".25": "rgba(52, 152, 219, .9)", ".50": "rgba(241, 196, 15, .9)", ".75": "rgba(230, 126, 34, .9)", "1.0": "rgba(211, 84, 0, .9)" }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null + }, repaint = function() { + canvas.width = window2.innerWidth; + canvas.height = options.barThickness * 5; + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, createCanvas = function() { + canvas = document2.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document2.body.appendChild(canvas); + addEvent(window2, "resize", repaint); + }, topbar2 = { + config: function(opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function(delay) { + if (showing) return; + if (delay) { + if (progressTimerId) return; + progressTimerId = setTimeout(() => topbar2.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window2.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar2.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window2.requestAnimationFrame(loop); + topbar2.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function(to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 ? currentProgress : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function() { + clearTimeout(progressTimerId); + progressTimerId = null; + if (!showing) return; + showing = false; + if (canvas != null) { + (function loop() { + if (topbar2.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window2.requestAnimationFrame(loop); + })(); + } + } + }; + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar2; + } else if (typeof define === "function" && define.amd) { + define(function() { + return topbar2; + }); + } else { + this.topbar = topbar2; + } + }).call(exports, window, document); + } + }); + + // ../../../deps/phoenix_html/priv/static/phoenix_html.js + (function() { + var PolyfillEvent = eventConstructor(); + function eventConstructor() { + if (typeof window.CustomEvent === "function") return window.CustomEvent; + function CustomEvent2(event, params) { + params = params || { bubbles: false, cancelable: false, detail: void 0 }; + var evt = document.createEvent("CustomEvent"); + evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); + return evt; + } + CustomEvent2.prototype = window.Event.prototype; + return CustomEvent2; + } + function buildHiddenInput(name, value) { + var input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + return input; + } + function handleClick(element, targetModifierKey) { + var to = element.getAttribute("data-to"), method = buildHiddenInput("_method", element.getAttribute("data-method")), csrf = buildHiddenInput("_csrf_token", element.getAttribute("data-csrf")), form = document.createElement("form"), submit = document.createElement("input"), target = element.getAttribute("target"); + form.method = element.getAttribute("data-method") === "get" ? "get" : "post"; + form.action = to; + form.style.display = "none"; + if (target) form.target = target; + else if (targetModifierKey) form.target = "_blank"; + form.appendChild(csrf); + form.appendChild(method); + document.body.appendChild(form); + submit.type = "submit"; + form.appendChild(submit); + submit.click(); + } + window.addEventListener("click", function(e) { + var element = e.target; + if (e.defaultPrevented) return; + while (element && element.getAttribute) { + var phoenixLinkEvent = new PolyfillEvent("phoenix.link.click", { + "bubbles": true, + "cancelable": true + }); + if (!element.dispatchEvent(phoenixLinkEvent)) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + if (element.getAttribute("data-method") && element.getAttribute("data-to")) { + handleClick(element, e.metaKey || e.shiftKey); + e.preventDefault(); + return false; + } else { + element = element.parentNode; + } + } + }, false); + window.addEventListener("phoenix.link.click", function(e) { + var message = e.target.getAttribute("data-confirm"); + if (message && !window.confirm(message)) { + e.preventDefault(); + } + }, false); + })(); + + // ../../../deps/phoenix/priv/static/phoenix.mjs + var closure = (value) => { + if (typeof value === "function") { + return value; + } else { + let closure22 = function() { + return value; + }; + return closure22; + } + }; + var globalSelf = typeof self !== "undefined" ? self : null; + var phxWindow = typeof window !== "undefined" ? window : null; + var global = globalSelf || phxWindow || globalThis; + var DEFAULT_VSN = "2.0.0"; + var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; + var MAX_LONGPOLL_BATCH_SIZE = 100; + var DEFAULT_TIMEOUT = 1e4; + var WS_CLOSE_NORMAL = 1e3; + var CHANNEL_STATES = { + closed: "closed", + errored: "errored", + joined: "joined", + joining: "joining", + leaving: "leaving" + }; + var CHANNEL_EVENTS = { + close: "phx_close", + error: "phx_error", + join: "phx_join", + reply: "phx_reply", + leave: "phx_leave" + }; + var TRANSPORTS = { + longpoll: "longpoll", + websocket: "websocket" + }; + var XHR_STATES = { + complete: 4 + }; + var AUTH_TOKEN_PREFIX = "base64url.bearer.phx."; + var Push = class { + constructor(channel, event, payload, timeout) { + this.channel = channel; + this.event = event; + this.payload = payload || function() { + return {}; + }; + this.receivedResp = null; + this.timeout = timeout; + this.timeoutTimer = null; + this.recHooks = []; + this.sent = false; + } + /** + * + * @param {number} timeout + */ + resend(timeout) { + this.timeout = timeout; + this.reset(); + this.send(); + } + /** + * + */ + send() { + if (this.hasReceived("timeout")) { + return; + } + this.startTimeout(); + this.sent = true; + this.channel.socket.push({ + topic: this.channel.topic, + event: this.event, + payload: this.payload(), + ref: this.ref, + join_ref: this.channel.joinRef() + }); + } + /** + * + * @param {*} status + * @param {*} callback + */ + receive(status, callback) { + if (this.hasReceived(status)) { + callback(this.receivedResp.response); + } + this.recHooks.push({ status, callback }); + return this; + } + /** + * @private + */ + reset() { + this.cancelRefEvent(); + this.ref = null; + this.refEvent = null; + this.receivedResp = null; + this.sent = false; + } + /** + * @private + */ + matchReceive({ status, response, _ref }) { + this.recHooks.filter((h) => h.status === status).forEach((h) => h.callback(response)); + } + /** + * @private + */ + cancelRefEvent() { + if (!this.refEvent) { + return; + } + this.channel.off(this.refEvent); + } + /** + * @private + */ + cancelTimeout() { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + /** + * @private + */ + startTimeout() { + if (this.timeoutTimer) { + this.cancelTimeout(); + } + this.ref = this.channel.socket.makeRef(); + this.refEvent = this.channel.replyEventName(this.ref); + this.channel.on(this.refEvent, (payload) => { + this.cancelRefEvent(); + this.cancelTimeout(); + this.receivedResp = payload; + this.matchReceive(payload); + }); + this.timeoutTimer = setTimeout(() => { + this.trigger("timeout", {}); + }, this.timeout); + } + /** + * @private + */ + hasReceived(status) { + return this.receivedResp && this.receivedResp.status === status; + } + /** + * @private + */ + trigger(status, response) { + this.channel.trigger(this.refEvent, { status, response }); + } + }; + var Timer = class { + constructor(callback, timerCalc) { + this.callback = callback; + this.timerCalc = timerCalc; + this.timer = null; + this.tries = 0; + } + reset() { + this.tries = 0; + clearTimeout(this.timer); + } + /** + * Cancels any previous scheduleTimeout and schedules callback + */ + scheduleTimeout() { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.tries = this.tries + 1; + this.callback(); + }, this.timerCalc(this.tries + 1)); + } + }; + var Channel = class { + constructor(topic, params, socket) { + this.state = CHANNEL_STATES.closed; + this.topic = topic; + this.params = closure(params || {}); + this.socket = socket; + this.bindings = []; + this.bindingRef = 0; + this.timeout = this.socket.timeout; + this.joinedOnce = false; + this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); + this.pushBuffer = []; + this.stateChangeRefs = []; + this.rejoinTimer = new Timer(() => { + if (this.socket.isConnected()) { + this.rejoin(); + } + }, this.socket.rejoinAfterMs); + this.stateChangeRefs.push(this.socket.onError(() => this.rejoinTimer.reset())); + this.stateChangeRefs.push( + this.socket.onOpen(() => { + this.rejoinTimer.reset(); + if (this.isErrored()) { + this.rejoin(); + } + }) + ); + this.joinPush.receive("ok", () => { + this.state = CHANNEL_STATES.joined; + this.rejoinTimer.reset(); + this.pushBuffer.forEach((pushEvent) => pushEvent.send()); + this.pushBuffer = []; + }); + this.joinPush.receive("error", () => { + this.state = CHANNEL_STATES.errored; + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.onClose(() => { + this.rejoinTimer.reset(); + if (this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`); + this.state = CHANNEL_STATES.closed; + this.socket.remove(this); + }); + this.onError((reason) => { + if (this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason); + if (this.isJoining()) { + this.joinPush.reset(); + } + this.state = CHANNEL_STATES.errored; + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.joinPush.receive("timeout", () => { + if (this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout); + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout); + leavePush.send(); + this.state = CHANNEL_STATES.errored; + this.joinPush.reset(); + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.on(CHANNEL_EVENTS.reply, (payload, ref) => { + this.trigger(this.replyEventName(ref), payload); + }); + } + /** + * Join the channel + * @param {integer} timeout + * @returns {Push} + */ + join(timeout = this.timeout) { + if (this.joinedOnce) { + throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance"); + } else { + this.timeout = timeout; + this.joinedOnce = true; + this.rejoin(); + return this.joinPush; + } + } + /** + * Hook into channel close + * @param {Function} callback + */ + onClose(callback) { + this.on(CHANNEL_EVENTS.close, callback); + } + /** + * Hook into channel errors + * @param {Function} callback + */ + onError(callback) { + return this.on(CHANNEL_EVENTS.error, (reason) => callback(reason)); + } + /** + * Subscribes on channel events + * + * Subscription returns a ref counter, which can be used later to + * unsubscribe the exact event listener + * + * @example + * const ref1 = channel.on("event", do_stuff) + * const ref2 = channel.on("event", do_other_stuff) + * channel.off("event", ref1) + * // Since unsubscription, do_stuff won't fire, + * // while do_other_stuff will keep firing on the "event" + * + * @param {string} event + * @param {Function} callback + * @returns {integer} ref + */ + on(event, callback) { + let ref = this.bindingRef++; + this.bindings.push({ event, ref, callback }); + return ref; + } + /** + * Unsubscribes off of channel events + * + * Use the ref returned from a channel.on() to unsubscribe one + * handler, or pass nothing for the ref to unsubscribe all + * handlers for the given event. + * + * @example + * // Unsubscribe the do_stuff handler + * const ref1 = channel.on("event", do_stuff) + * channel.off("event", ref1) + * + * // Unsubscribe all handlers from event + * channel.off("event") + * + * @param {string} event + * @param {integer} ref + */ + off(event, ref) { + this.bindings = this.bindings.filter((bind) => { + return !(bind.event === event && (typeof ref === "undefined" || ref === bind.ref)); + }); + } + /** + * @private + */ + canPush() { + return this.socket.isConnected() && this.isJoined(); + } + /** + * Sends a message `event` to phoenix with the payload `payload`. + * Phoenix receives this in the `handle_in(event, payload, socket)` + * function. if phoenix replies or it times out (default 10000ms), + * then optionally the reply can be received. + * + * @example + * channel.push("event") + * .receive("ok", payload => console.log("phoenix replied:", payload)) + * .receive("error", err => console.log("phoenix errored", err)) + * .receive("timeout", () => console.log("timed out pushing")) + * @param {string} event + * @param {Object} payload + * @param {number} [timeout] + * @returns {Push} + */ + push(event, payload, timeout = this.timeout) { + payload = payload || {}; + if (!this.joinedOnce) { + throw new Error(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`); + } + let pushEvent = new Push(this, event, function() { + return payload; + }, timeout); + if (this.canPush()) { + pushEvent.send(); + } else { + pushEvent.startTimeout(); + this.pushBuffer.push(pushEvent); + } + return pushEvent; + } + /** Leaves the channel + * + * Unsubscribes from server events, and + * instructs channel to terminate on server + * + * Triggers onClose() hooks + * + * To receive leave acknowledgements, use the `receive` + * hook to bind to the server ack, ie: + * + * @example + * channel.leave().receive("ok", () => alert("left!") ) + * + * @param {integer} timeout + * @returns {Push} + */ + leave(timeout = this.timeout) { + this.rejoinTimer.reset(); + this.joinPush.cancelTimeout(); + this.state = CHANNEL_STATES.leaving; + let onClose = () => { + if (this.socket.hasLogger()) this.socket.log("channel", `leave ${this.topic}`); + this.trigger(CHANNEL_EVENTS.close, "leave"); + }; + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), timeout); + leavePush.receive("ok", () => onClose()).receive("timeout", () => onClose()); + leavePush.send(); + if (!this.canPush()) { + leavePush.trigger("ok", {}); + } + return leavePush; + } + /** + * Overridable message hook + * + * Receives all events for specialized message handling + * before dispatching to the channel callbacks. + * + * Must return the payload, modified or unmodified + * @param {string} event + * @param {Object} payload + * @param {integer} ref + * @returns {Object} + */ + onMessage(_event, payload, _ref) { + return payload; + } + /** + * @private + */ + isMember(topic, event, payload, joinRef) { + if (this.topic !== topic) { + return false; + } + if (joinRef && joinRef !== this.joinRef()) { + if (this.socket.hasLogger()) this.socket.log("channel", "dropping outdated message", { topic, event, payload, joinRef }); + return false; + } else { + return true; + } + } + /** + * @private + */ + joinRef() { + return this.joinPush.ref; + } + /** + * @private + */ + rejoin(timeout = this.timeout) { + if (this.isLeaving()) { + return; + } + this.socket.leaveOpenTopic(this.topic); + this.state = CHANNEL_STATES.joining; + this.joinPush.resend(timeout); + } + /** + * @private + */ + trigger(event, payload, ref, joinRef) { + let handledPayload = this.onMessage(event, payload, ref, joinRef); + if (payload && !handledPayload) { + throw new Error("channel onMessage callbacks must return the payload, modified or unmodified"); + } + let eventBindings = this.bindings.filter((bind) => bind.event === event); + for (let i = 0; i < eventBindings.length; i++) { + let bind = eventBindings[i]; + bind.callback(handledPayload, ref, joinRef || this.joinRef()); + } + } + /** + * @private + */ + replyEventName(ref) { + return `chan_reply_${ref}`; + } + /** + * @private + */ + isClosed() { + return this.state === CHANNEL_STATES.closed; + } + /** + * @private + */ + isErrored() { + return this.state === CHANNEL_STATES.errored; + } + /** + * @private + */ + isJoined() { + return this.state === CHANNEL_STATES.joined; + } + /** + * @private + */ + isJoining() { + return this.state === CHANNEL_STATES.joining; + } + /** + * @private + */ + isLeaving() { + return this.state === CHANNEL_STATES.leaving; + } + }; + var Ajax = class { + static request(method, endPoint, headers, body, timeout, ontimeout, callback) { + if (global.XDomainRequest) { + let req = new global.XDomainRequest(); + return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); + } else if (global.XMLHttpRequest) { + let req = new global.XMLHttpRequest(); + return this.xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback); + } else if (global.fetch && global.AbortController) { + return this.fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback); + } else { + throw new Error("No suitable XMLHttpRequest implementation found"); + } + } + static fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback) { + let options = { + method, + headers, + body + }; + let controller = null; + if (timeout) { + controller = new AbortController(); + const _timeoutId = setTimeout(() => controller.abort(), timeout); + options.signal = controller.signal; + } + global.fetch(endPoint, options).then((response) => response.text()).then((data) => this.parseJSON(data)).then((data) => callback && callback(data)).catch((err) => { + if (err.name === "AbortError" && ontimeout) { + ontimeout(); + } else { + callback && callback(null); + } + }); + return controller; + } + static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { + req.timeout = timeout; + req.open(method, endPoint); + req.onload = () => { + let response = this.parseJSON(req.responseText); + callback && callback(response); + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + req.onprogress = () => { + }; + req.send(body); + return req; + } + static xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback) { + req.open(method, endPoint, true); + req.timeout = timeout; + for (let [key, value] of Object.entries(headers)) { + req.setRequestHeader(key, value); + } + req.onerror = () => callback && callback(null); + req.onreadystatechange = () => { + if (req.readyState === XHR_STATES.complete && callback) { + let response = this.parseJSON(req.responseText); + callback(response); + } + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + req.send(body); + return req; + } + static parseJSON(resp) { + if (!resp || resp === "") { + return null; + } + try { + return JSON.parse(resp); + } catch { + console && console.log("failed to parse JSON response", resp); + return null; + } + } + static serialize(obj, parentKey) { + let queryStr = []; + for (var key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + continue; + } + let paramKey = parentKey ? `${parentKey}[${key}]` : key; + let paramVal = obj[key]; + if (typeof paramVal === "object") { + queryStr.push(this.serialize(paramVal, paramKey)); + } else { + queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); + } + } + return queryStr.join("&"); + } + static appendParams(url, params) { + if (Object.keys(params).length === 0) { + return url; + } + let prefix = url.match(/\?/) ? "&" : "?"; + return `${url}${prefix}${this.serialize(params)}`; + } + }; + var arrayBufferToBase64 = (buffer) => { + let binary = ""; + let bytes = new Uint8Array(buffer); + let len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + }; + var LongPoll = class { + constructor(endPoint, protocols) { + if (protocols && protocols.length === 2 && protocols[1].startsWith(AUTH_TOKEN_PREFIX)) { + this.authToken = atob(protocols[1].slice(AUTH_TOKEN_PREFIX.length)); + } + this.endPoint = null; + this.token = null; + this.skipHeartbeat = true; + this.reqs = /* @__PURE__ */ new Set(); + this.awaitingBatchAck = false; + this.currentBatch = null; + this.currentBatchTimer = null; + this.batchBuffer = []; + this.onopen = function() { + }; + this.onerror = function() { + }; + this.onmessage = function() { + }; + this.onclose = function() { + }; + this.pollEndpoint = this.normalizeEndpoint(endPoint); + this.readyState = SOCKET_STATES.connecting; + setTimeout(() => this.poll(), 0); + } + normalizeEndpoint(endPoint) { + return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); + } + endpointURL() { + return Ajax.appendParams(this.pollEndpoint, { token: this.token }); + } + closeAndRetry(code, reason, wasClean) { + this.close(code, reason, wasClean); + this.readyState = SOCKET_STATES.connecting; + } + ontimeout() { + this.onerror("timeout"); + this.closeAndRetry(1005, "timeout", false); + } + isActive() { + return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting; + } + poll() { + const headers = { "Accept": "application/json" }; + if (this.authToken) { + headers["X-Phoenix-AuthToken"] = this.authToken; + } + this.ajax("GET", headers, null, () => this.ontimeout(), (resp) => { + if (resp) { + var { status, token, messages } = resp; + if (status === 410 && this.token !== null) { + this.onerror(410); + this.closeAndRetry(3410, "session_gone", false); + return; + } + this.token = token; + } else { + status = 0; + } + switch (status) { + case 200: + messages.forEach((msg) => { + setTimeout(() => this.onmessage({ data: msg }), 0); + }); + this.poll(); + break; + case 204: + this.poll(); + break; + case 410: + this.readyState = SOCKET_STATES.open; + this.onopen({}); + this.poll(); + break; + case 403: + this.onerror(403); + this.close(1008, "forbidden", false); + break; + case 0: + case 500: + this.onerror(500); + this.closeAndRetry(1011, "internal server error", 500); + break; + default: + throw new Error(`unhandled poll status ${status}`); + } + }); + } + // we collect all pushes within the current event loop by + // setTimeout 0, which optimizes back-to-back procedural + // pushes against an empty buffer + send(body) { + if (typeof body !== "string") { + body = arrayBufferToBase64(body); + } + if (this.currentBatch) { + this.currentBatch.push(body); + } else if (this.awaitingBatchAck) { + this.batchBuffer.push(body); + } else { + this.currentBatch = [body]; + this.currentBatchTimer = setTimeout(() => { + this.batchSend(this.currentBatch); + this.currentBatch = null; + }, 0); + } + } + batchSend(messages, offset = 0) { + this.awaitingBatchAck = true; + const next = offset + MAX_LONGPOLL_BATCH_SIZE; + const batch = messages.slice(offset, next); + this.ajax("POST", { "Content-Type": "application/x-ndjson" }, batch.join("\n"), () => this.onerror("timeout"), (resp) => { + if (!resp || resp.status !== 200) { + this.awaitingBatchAck = false; + this.onerror(resp && resp.status); + this.closeAndRetry(1011, "internal server error", false); + } else if (next < messages.length) { + this.batchSend(messages, next); + } else if (this.batchBuffer.length > 0) { + this.batchSend(this.batchBuffer); + this.batchBuffer = []; + } else { + this.awaitingBatchAck = false; + } + }); + } + close(code, reason, wasClean) { + for (let req of this.reqs) { + req.abort(); + } + this.readyState = SOCKET_STATES.closed; + let opts = Object.assign({ code: 1e3, reason: void 0, wasClean: true }, { code, reason, wasClean }); + this.batchBuffer = []; + clearTimeout(this.currentBatchTimer); + this.currentBatchTimer = null; + if (typeof CloseEvent !== "undefined") { + this.onclose(new CloseEvent("close", opts)); + } else { + this.onclose(opts); + } + } + ajax(method, headers, body, onCallerTimeout, callback) { + let req; + let ontimeout = () => { + this.reqs.delete(req); + onCallerTimeout(); + }; + req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, (resp) => { + this.reqs.delete(req); + if (this.isActive()) { + callback(resp); + } + }); + this.reqs.add(req); + } + }; + var serializer_default = { + HEADER_LENGTH: 1, + META_LENGTH: 4, + KINDS: { push: 0, reply: 1, broadcast: 2 }, + encode(msg, callback) { + if (msg.payload.constructor === ArrayBuffer) { + return callback(this.binaryEncode(msg)); + } else { + let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]; + return callback(JSON.stringify(payload)); + } + }, + decode(rawPayload, callback) { + if (rawPayload.constructor === ArrayBuffer) { + return callback(this.binaryDecode(rawPayload)); + } else { + let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload); + return callback({ join_ref, ref, topic, event, payload }); + } + }, + // private + binaryEncode(message) { + let { join_ref, ref, event, topic, payload } = message; + let encoder = new TextEncoder(); + let joinRefBytes = encoder.encode(join_ref); + let refBytes = encoder.encode(ref); + let topicBytes = encoder.encode(topic); + let eventBytes = encoder.encode(event); + this.assertFieldSize(joinRefBytes.byteLength, "join_ref"); + this.assertFieldSize(refBytes.byteLength, "ref"); + this.assertFieldSize(topicBytes.byteLength, "topic"); + this.assertFieldSize(eventBytes.byteLength, "event"); + let metaLength = this.META_LENGTH + joinRefBytes.byteLength + refBytes.byteLength + topicBytes.byteLength + eventBytes.byteLength; + let header = new ArrayBuffer(this.HEADER_LENGTH + metaLength); + let headerBytes = new Uint8Array(header); + let view = new DataView(header); + let offset = 0; + view.setUint8(offset++, this.KINDS.push); + view.setUint8(offset++, joinRefBytes.byteLength); + view.setUint8(offset++, refBytes.byteLength); + view.setUint8(offset++, topicBytes.byteLength); + view.setUint8(offset++, eventBytes.byteLength); + headerBytes.set(joinRefBytes, offset); + offset += joinRefBytes.byteLength; + headerBytes.set(refBytes, offset); + offset += refBytes.byteLength; + headerBytes.set(topicBytes, offset); + offset += topicBytes.byteLength; + headerBytes.set(eventBytes, offset); + offset += eventBytes.byteLength; + var combined = new Uint8Array(header.byteLength + payload.byteLength); + combined.set(headerBytes, 0); + combined.set(new Uint8Array(payload), header.byteLength); + return combined.buffer; + }, + assertFieldSize(size, name) { + if (size > 255) { + throw new Error(`unable to convert ${name} to binary: must be less than or equal to 255 bytes, but is ${size} bytes`); + } + }, + binaryDecode(buffer) { + let view = new DataView(buffer); + let kind = view.getUint8(0); + let decoder = new TextDecoder(); + switch (kind) { + case this.KINDS.push: + return this.decodePush(buffer, view, decoder); + case this.KINDS.reply: + return this.decodeReply(buffer, view, decoder); + case this.KINDS.broadcast: + return this.decodeBroadcast(buffer, view, decoder); + } + }, + decodePush(buffer, view, decoder) { + let joinRefSize = view.getUint8(1); + let topicSize = view.getUint8(2); + let eventSize = view.getUint8(3); + let offset = this.HEADER_LENGTH + this.META_LENGTH - 1; + let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)); + offset = offset + joinRefSize; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + return { join_ref: joinRef, ref: null, topic, event, payload: data }; + }, + decodeReply(buffer, view, decoder) { + let joinRefSize = view.getUint8(1); + let refSize = view.getUint8(2); + let topicSize = view.getUint8(3); + let eventSize = view.getUint8(4); + let offset = this.HEADER_LENGTH + this.META_LENGTH; + let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)); + offset = offset + joinRefSize; + let ref = decoder.decode(buffer.slice(offset, offset + refSize)); + offset = offset + refSize; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + let payload = { status: event, response: data }; + return { join_ref: joinRef, ref, topic, event: CHANNEL_EVENTS.reply, payload }; + }, + decodeBroadcast(buffer, view, decoder) { + let topicSize = view.getUint8(1); + let eventSize = view.getUint8(2); + let offset = this.HEADER_LENGTH + 2; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + return { join_ref: null, ref: null, topic, event, payload: data }; + } + }; + var Socket = class { + constructor(endPoint, opts = {}) { + this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; + this.channels = []; + this.sendBuffer = []; + this.ref = 0; + this.fallbackRef = null; + this.timeout = opts.timeout || DEFAULT_TIMEOUT; + this.transport = opts.transport || global.WebSocket || LongPoll; + this.primaryPassedHealthCheck = false; + this.longPollFallbackMs = opts.longPollFallbackMs; + this.fallbackTimer = null; + this.sessionStore = opts.sessionStorage || global && global.sessionStorage; + this.establishedConnections = 0; + this.defaultEncoder = serializer_default.encode.bind(serializer_default); + this.defaultDecoder = serializer_default.decode.bind(serializer_default); + this.closeWasClean = true; + this.disconnecting = false; + this.binaryType = opts.binaryType || "arraybuffer"; + this.connectClock = 1; + this.pageHidden = false; + if (this.transport !== LongPoll) { + this.encode = opts.encode || this.defaultEncoder; + this.decode = opts.decode || this.defaultDecoder; + } else { + this.encode = this.defaultEncoder; + this.decode = this.defaultDecoder; + } + let awaitingConnectionOnPageShow = null; + if (phxWindow && phxWindow.addEventListener) { + phxWindow.addEventListener("pagehide", (_e) => { + if (this.conn) { + this.disconnect(); + awaitingConnectionOnPageShow = this.connectClock; + } + }); + phxWindow.addEventListener("pageshow", (_e) => { + if (awaitingConnectionOnPageShow === this.connectClock) { + awaitingConnectionOnPageShow = null; + this.connect(); + } + }); + phxWindow.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + this.pageHidden = true; + } else { + this.pageHidden = false; + if (!this.isConnected() && !this.closeWasClean) { + this.teardown(() => this.connect()); + } + } + }); + } + this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 3e4; + this.rejoinAfterMs = (tries) => { + if (opts.rejoinAfterMs) { + return opts.rejoinAfterMs(tries); + } else { + return [1e3, 2e3, 5e3][tries - 1] || 1e4; + } + }; + this.reconnectAfterMs = (tries) => { + if (opts.reconnectAfterMs) { + return opts.reconnectAfterMs(tries); + } else { + return [10, 50, 100, 150, 200, 250, 500, 1e3, 2e3][tries - 1] || 5e3; + } + }; + this.logger = opts.logger || null; + if (!this.logger && opts.debug) { + this.logger = (kind, msg, data) => { + console.log(`${kind}: ${msg}`, data); + }; + } + this.longpollerTimeout = opts.longpollerTimeout || 2e4; + this.params = closure(opts.params || {}); + this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`; + this.vsn = opts.vsn || DEFAULT_VSN; + this.heartbeatTimeoutTimer = null; + this.heartbeatTimer = null; + this.pendingHeartbeatRef = null; + this.reconnectTimer = new Timer(() => { + if (this.pageHidden) { + this.log("Not reconnecting as page is hidden!"); + this.teardown(); + return; + } + this.teardown(() => this.connect()); + }, this.reconnectAfterMs); + this.authToken = opts.authToken; + } + /** + * Returns the LongPoll transport reference + */ + getLongPollTransport() { + return LongPoll; + } + /** + * Disconnects and replaces the active transport + * + * @param {Function} newTransport - The new transport class to instantiate + * + */ + replaceTransport(newTransport) { + this.connectClock++; + this.closeWasClean = true; + clearTimeout(this.fallbackTimer); + this.reconnectTimer.reset(); + if (this.conn) { + this.conn.close(); + this.conn = null; + } + this.transport = newTransport; + } + /** + * Returns the socket protocol + * + * @returns {string} + */ + protocol() { + return location.protocol.match(/^https/) ? "wss" : "ws"; + } + /** + * The fully qualified socket url + * + * @returns {string} + */ + endPointURL() { + let uri = Ajax.appendParams( + Ajax.appendParams(this.endPoint, this.params()), + { vsn: this.vsn } + ); + if (uri.charAt(0) !== "/") { + return uri; + } + if (uri.charAt(1) === "/") { + return `${this.protocol()}:${uri}`; + } + return `${this.protocol()}://${location.host}${uri}`; + } + /** + * Disconnects the socket + * + * See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes for valid status codes. + * + * @param {Function} callback - Optional callback which is called after socket is disconnected. + * @param {integer} code - A status code for disconnection (Optional). + * @param {string} reason - A textual description of the reason to disconnect. (Optional) + */ + disconnect(callback, code, reason) { + this.connectClock++; + this.disconnecting = true; + this.closeWasClean = true; + clearTimeout(this.fallbackTimer); + this.reconnectTimer.reset(); + this.teardown(() => { + this.disconnecting = false; + callback && callback(); + }, code, reason); + } + /** + * + * @param {Object} params - The params to send when connecting, for example `{user_id: userToken}` + * + * Passing params to connect is deprecated; pass them in the Socket constructor instead: + * `new Socket("/socket", {params: {user_id: userToken}})`. + */ + connect(params) { + if (params) { + console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); + this.params = closure(params); + } + if (this.conn && !this.disconnecting) { + return; + } + if (this.longPollFallbackMs && this.transport !== LongPoll) { + this.connectWithFallback(LongPoll, this.longPollFallbackMs); + } else { + this.transportConnect(); + } + } + /** + * Logs the message. Override `this.logger` for specialized logging. noops by default + * @param {string} kind + * @param {string} msg + * @param {Object} data + */ + log(kind, msg, data) { + this.logger && this.logger(kind, msg, data); + } + /** + * Returns true if a logger has been set on this socket. + */ + hasLogger() { + return this.logger !== null; + } + /** + * Registers callbacks for connection open events + * + * @example socket.onOpen(function(){ console.info("the socket was opened") }) + * + * @param {Function} callback + */ + onOpen(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.open.push([ref, callback]); + return ref; + } + /** + * Registers callbacks for connection close events + * @param {Function} callback + */ + onClose(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.close.push([ref, callback]); + return ref; + } + /** + * Registers callbacks for connection error events + * + * @example socket.onError(function(error){ alert("An error occurred") }) + * + * @param {Function} callback + */ + onError(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.error.push([ref, callback]); + return ref; + } + /** + * Registers callbacks for connection message events + * @param {Function} callback + */ + onMessage(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.message.push([ref, callback]); + return ref; + } + /** + * Pings the server and invokes the callback with the RTT in milliseconds + * @param {Function} callback + * + * Returns true if the ping was pushed or false if unable to be pushed. + */ + ping(callback) { + if (!this.isConnected()) { + return false; + } + let ref = this.makeRef(); + let startTime = Date.now(); + this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref }); + let onMsgRef = this.onMessage((msg) => { + if (msg.ref === ref) { + this.off([onMsgRef]); + callback(Date.now() - startTime); + } + }); + return true; + } + /** + * @private + * + * @param {Function} + */ + transportName(transport) { + switch (transport) { + case LongPoll: + return "LongPoll"; + default: + return transport.name; + } + } + /** + * @private + */ + transportConnect() { + this.connectClock++; + this.closeWasClean = false; + let protocols = void 0; + if (this.authToken) { + protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${btoa(this.authToken).replace(/=/g, "")}`]; + } + this.conn = new this.transport(this.endPointURL(), protocols); + this.conn.binaryType = this.binaryType; + this.conn.timeout = this.longpollerTimeout; + this.conn.onopen = () => this.onConnOpen(); + this.conn.onerror = (error) => this.onConnError(error); + this.conn.onmessage = (event) => this.onConnMessage(event); + this.conn.onclose = (event) => this.onConnClose(event); + } + getSession(key) { + return this.sessionStore && this.sessionStore.getItem(key); + } + storeSession(key, val) { + this.sessionStore && this.sessionStore.setItem(key, val); + } + connectWithFallback(fallbackTransport, fallbackThreshold = 2500) { + clearTimeout(this.fallbackTimer); + let established = false; + let primaryTransport = true; + let openRef, errorRef; + let fallbackTransportName = this.transportName(fallbackTransport); + let fallback = (reason) => { + this.log("transport", `falling back to ${fallbackTransportName}...`, reason); + this.off([openRef, errorRef]); + primaryTransport = false; + this.replaceTransport(fallbackTransport); + this.transportConnect(); + }; + if (this.getSession(`phx:fallback:${fallbackTransportName}`)) { + return fallback("memorized"); + } + this.fallbackTimer = setTimeout(fallback, fallbackThreshold); + errorRef = this.onError((reason) => { + this.log("transport", "error", reason); + if (primaryTransport && !established) { + clearTimeout(this.fallbackTimer); + fallback(reason); + } + }); + if (this.fallbackRef) { + this.off([this.fallbackRef]); + } + this.fallbackRef = this.onOpen(() => { + established = true; + if (!primaryTransport) { + let fallbackTransportName2 = this.transportName(fallbackTransport); + if (!this.primaryPassedHealthCheck) { + this.storeSession(`phx:fallback:${fallbackTransportName2}`, "true"); + } + return this.log("transport", `established ${fallbackTransportName2} fallback`); + } + clearTimeout(this.fallbackTimer); + this.fallbackTimer = setTimeout(fallback, fallbackThreshold); + this.ping((rtt) => { + this.log("transport", "connected to primary after", rtt); + this.primaryPassedHealthCheck = true; + clearTimeout(this.fallbackTimer); + }); + }); + this.transportConnect(); + } + clearHeartbeats() { + clearTimeout(this.heartbeatTimer); + clearTimeout(this.heartbeatTimeoutTimer); + } + onConnOpen() { + if (this.hasLogger()) this.log("transport", `${this.transportName(this.transport)} connected to ${this.endPointURL()}`); + this.closeWasClean = false; + this.disconnecting = false; + this.establishedConnections++; + this.flushSendBuffer(); + this.reconnectTimer.reset(); + this.resetHeartbeat(); + this.stateChangeCallbacks.open.forEach(([, callback]) => callback()); + } + /** + * @private + */ + heartbeatTimeout() { + if (this.pendingHeartbeatRef) { + this.pendingHeartbeatRef = null; + if (this.hasLogger()) { + this.log("transport", "heartbeat timeout. Attempting to re-establish connection"); + } + this.triggerChanError(); + this.closeWasClean = false; + this.teardown(() => this.reconnectTimer.scheduleTimeout(), WS_CLOSE_NORMAL, "heartbeat timeout"); + } + } + resetHeartbeat() { + if (this.conn && this.conn.skipHeartbeat) { + return; + } + this.pendingHeartbeatRef = null; + this.clearHeartbeats(); + this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs); + } + teardown(callback, code, reason) { + if (!this.conn) { + return callback && callback(); + } + const connToClose = this.conn; + this.waitForBufferDone(connToClose, () => { + if (code) { + connToClose.close(code, reason || ""); + } else { + connToClose.close(); + } + this.waitForSocketClosed(connToClose, () => { + if (this.conn === connToClose) { + this.conn.onopen = function() { + }; + this.conn.onerror = function() { + }; + this.conn.onmessage = function() { + }; + this.conn.onclose = function() { + }; + this.conn = null; + } + callback && callback(); + }); + }); + } + waitForBufferDone(conn, callback, tries = 1) { + if (tries === 5 || !conn.bufferedAmount) { + callback(); + return; + } + setTimeout(() => { + this.waitForBufferDone(conn, callback, tries + 1); + }, 150 * tries); + } + waitForSocketClosed(conn, callback, tries = 1) { + if (tries === 5 || conn.readyState === SOCKET_STATES.closed) { + callback(); + return; + } + setTimeout(() => { + this.waitForSocketClosed(conn, callback, tries + 1); + }, 150 * tries); + } + onConnClose(event) { + if (this.conn) this.conn.onclose = () => { + }; + let closeCode = event && event.code; + if (this.hasLogger()) this.log("transport", "close", event); + this.triggerChanError(); + this.clearHeartbeats(); + if (!this.closeWasClean && closeCode !== 1e3) { + this.reconnectTimer.scheduleTimeout(); + } + this.stateChangeCallbacks.close.forEach(([, callback]) => callback(event)); + } + /** + * @private + */ + onConnError(error) { + if (this.hasLogger()) this.log("transport", "error", error); + let transportBefore = this.transport; + let establishedBefore = this.establishedConnections; + this.stateChangeCallbacks.error.forEach(([, callback]) => { + callback(error, transportBefore, establishedBefore); + }); + if (transportBefore === this.transport || establishedBefore > 0) { + this.triggerChanError(); + } + } + /** + * @private + */ + triggerChanError() { + this.channels.forEach((channel) => { + if (!(channel.isErrored() || channel.isLeaving() || channel.isClosed())) { + channel.trigger(CHANNEL_EVENTS.error); + } + }); + } + /** + * @returns {string} + */ + connectionState() { + switch (this.conn && this.conn.readyState) { + case SOCKET_STATES.connecting: + return "connecting"; + case SOCKET_STATES.open: + return "open"; + case SOCKET_STATES.closing: + return "closing"; + default: + return "closed"; + } + } + /** + * @returns {boolean} + */ + isConnected() { + return this.connectionState() === "open"; + } + /** + * @private + * + * @param {Channel} + */ + remove(channel) { + this.off(channel.stateChangeRefs); + this.channels = this.channels.filter((c) => c !== channel); + } + /** + * Removes `onOpen`, `onClose`, `onError,` and `onMessage` registrations. + * + * @param {refs} - list of refs returned by calls to + * `onOpen`, `onClose`, `onError,` and `onMessage` + */ + off(refs) { + for (let key in this.stateChangeCallbacks) { + this.stateChangeCallbacks[key] = this.stateChangeCallbacks[key].filter(([ref]) => { + return refs.indexOf(ref) === -1; + }); + } + } + /** + * Initiates a new channel for the given topic + * + * @param {string} topic + * @param {Object} chanParams - Parameters for the channel + * @returns {Channel} + */ + channel(topic, chanParams = {}) { + let chan = new Channel(topic, chanParams, this); + this.channels.push(chan); + return chan; + } + /** + * @param {Object} data + */ + push(data) { + if (this.hasLogger()) { + let { topic, event, payload, ref, join_ref } = data; + this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload); + } + if (this.isConnected()) { + this.encode(data, (result) => this.conn.send(result)); + } else { + this.sendBuffer.push(() => this.encode(data, (result) => this.conn.send(result))); + } + } + /** + * Return the next message ref, accounting for overflows + * @returns {string} + */ + makeRef() { + let newRef = this.ref + 1; + if (newRef === this.ref) { + this.ref = 0; + } else { + this.ref = newRef; + } + return this.ref.toString(); + } + sendHeartbeat() { + if (this.pendingHeartbeatRef && !this.isConnected()) { + return; + } + this.pendingHeartbeatRef = this.makeRef(); + this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef }); + this.heartbeatTimeoutTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs); + } + flushSendBuffer() { + if (this.isConnected() && this.sendBuffer.length > 0) { + this.sendBuffer.forEach((callback) => callback()); + this.sendBuffer = []; + } + } + onConnMessage(rawMessage) { + this.decode(rawMessage.data, (msg) => { + let { topic, event, payload, ref, join_ref } = msg; + if (ref && ref === this.pendingHeartbeatRef) { + this.clearHeartbeats(); + this.pendingHeartbeatRef = null; + this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs); + } + if (this.hasLogger()) this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`, payload); + for (let i = 0; i < this.channels.length; i++) { + const channel = this.channels[i]; + if (!channel.isMember(topic, event, payload, join_ref)) { + continue; + } + channel.trigger(event, payload, ref, join_ref); + } + for (let i = 0; i < this.stateChangeCallbacks.message.length; i++) { + let [, callback] = this.stateChangeCallbacks.message[i]; + callback(msg); + } + }); + } + leaveOpenTopic(topic) { + let dupChannel = this.channels.find((c) => c.topic === topic && (c.isJoined() || c.isJoining())); + if (dupChannel) { + if (this.hasLogger()) this.log("transport", `leaving duplicate topic "${topic}"`); + dupChannel.leave(); + } + } + }; + + // ../../../deps/phoenix_live_view/priv/static/phoenix_live_view.esm.js + var CONSECUTIVE_RELOADS = "consecutive-reloads"; + var MAX_RELOADS = 10; + var RELOAD_JITTER_MIN = 5e3; + var RELOAD_JITTER_MAX = 1e4; + var FAILSAFE_JITTER = 3e4; + var PHX_EVENT_CLASSES = [ + "phx-click-loading", + "phx-change-loading", + "phx-submit-loading", + "phx-keydown-loading", + "phx-keyup-loading", + "phx-blur-loading", + "phx-focus-loading", + "phx-hook-loading" + ]; + var PHX_DROP_TARGET_ACTIVE_CLASS = "phx-drop-target-active"; + var PHX_COMPONENT = "data-phx-component"; + var PHX_VIEW_REF = "data-phx-view"; + var PHX_LIVE_LINK = "data-phx-link"; + var PHX_TRACK_STATIC = "track-static"; + var PHX_LINK_STATE = "data-phx-link-state"; + var PHX_REF_LOADING = "data-phx-ref-loading"; + var PHX_REF_SRC = "data-phx-ref-src"; + var PHX_REF_LOCK = "data-phx-ref-lock"; + var PHX_PENDING_REFS = "phx-pending-refs"; + var PHX_TRACK_UPLOADS = "track-uploads"; + var PHX_UPLOAD_REF = "data-phx-upload-ref"; + var PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs"; + var PHX_DONE_REFS = "data-phx-done-refs"; + var PHX_DROP_TARGET = "drop-target"; + var PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"; + var PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"; + var PHX_SKIP = "data-phx-skip"; + var PHX_MAGIC_ID = "data-phx-id"; + var PHX_PRUNE = "data-phx-prune"; + var PHX_CONNECTED_CLASS = "phx-connected"; + var PHX_LOADING_CLASS = "phx-loading"; + var PHX_ERROR_CLASS = "phx-error"; + var PHX_CLIENT_ERROR_CLASS = "phx-client-error"; + var PHX_SERVER_ERROR_CLASS = "phx-server-error"; + var PHX_PARENT_ID = "data-phx-parent-id"; + var PHX_MAIN = "data-phx-main"; + var PHX_ROOT_ID = "data-phx-root-id"; + var PHX_VIEWPORT_TOP = "viewport-top"; + var PHX_VIEWPORT_BOTTOM = "viewport-bottom"; + var PHX_VIEWPORT_OVERRUN_TARGET = "viewport-overrun-target"; + var PHX_TRIGGER_ACTION = "trigger-action"; + var PHX_HAS_FOCUSED = "phx-has-focused"; + var FOCUSABLE_INPUTS = [ + "text", + "textarea", + "number", + "email", + "password", + "search", + "tel", + "url", + "date", + "time", + "datetime-local", + "color", + "range" + ]; + var CHECKABLE_INPUTS = ["checkbox", "radio"]; + var PHX_HAS_SUBMITTED = "phx-has-submitted"; + var PHX_SESSION = "data-phx-session"; + var PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; + var PHX_STICKY = "data-phx-sticky"; + var PHX_STATIC = "data-phx-static"; + var PHX_READONLY = "data-phx-readonly"; + var PHX_DISABLED = "data-phx-disabled"; + var PHX_DISABLE_WITH = "disable-with"; + var PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore"; + var PHX_HOOK = "hook"; + var PHX_DEBOUNCE = "debounce"; + var PHX_THROTTLE = "throttle"; + var PHX_UPDATE = "update"; + var PHX_STREAM = "stream"; + var PHX_STREAM_REF = "data-phx-stream"; + var PHX_PORTAL = "data-phx-portal"; + var PHX_TELEPORTED_REF = "data-phx-teleported"; + var PHX_TELEPORTED_SRC = "data-phx-teleported-src"; + var PHX_RUNTIME_HOOK = "data-phx-runtime-hook"; + var PHX_LV_PID = "data-phx-pid"; + var PHX_KEY = "key"; + var PHX_PRIVATE = "phxPrivate"; + var PHX_AUTO_RECOVER = "auto-recover"; + var PHX_LV_DEBUG = "phx:live-socket:debug"; + var PHX_LV_PROFILE = "phx:live-socket:profiling"; + var PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim"; + var PHX_LV_HISTORY_POSITION = "phx:nav-history-position"; + var PHX_PROGRESS = "progress"; + var PHX_MOUNTED = "mounted"; + var PHX_RELOAD_STATUS = "__phoenix_reload_status__"; + var LOADER_TIMEOUT = 1; + var MAX_CHILD_JOIN_ATTEMPTS = 3; + var BEFORE_UNLOAD_LOADER_TIMEOUT = 200; + var DISCONNECTED_TIMEOUT = 500; + var BINDING_PREFIX = "phx-"; + var PUSH_TIMEOUT = 3e4; + var DEBOUNCE_TRIGGER = "debounce-trigger"; + var THROTTLED = "throttled"; + var DEBOUNCE_PREV_KEY = "debounce-prev-key"; + var DEFAULTS = { + debounce: 300, + throttle: 300 + }; + var PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK]; + var STATIC = "s"; + var ROOT = "r"; + var COMPONENTS = "c"; + var KEYED = "k"; + var KEYED_COUNT = "kc"; + var EVENTS = "e"; + var REPLY = "r"; + var TITLE = "t"; + var TEMPLATES = "p"; + var STREAM = "stream"; + var EntryUploader = class { + constructor(entry, config, liveSocket2) { + const { chunk_size, chunk_timeout } = config; + this.liveSocket = liveSocket2; + this.entry = entry; + this.offset = 0; + this.chunkSize = chunk_size; + this.chunkTimeout = chunk_timeout; + this.chunkTimer = null; + this.errored = false; + this.uploadChannel = liveSocket2.channel(`lvu:${entry.ref}`, { + token: entry.metadata() + }); + } + error(reason) { + if (this.errored) { + return; + } + this.uploadChannel.leave(); + this.errored = true; + clearTimeout(this.chunkTimer); + this.entry.error(reason); + } + upload() { + this.uploadChannel.onError((reason) => this.error(reason)); + this.uploadChannel.join().receive("ok", (_data) => this.readNextChunk()).receive("error", (reason) => this.error(reason)); + } + isDone() { + return this.offset >= this.entry.file.size; + } + readNextChunk() { + const reader = new window.FileReader(); + const blob = this.entry.file.slice( + this.offset, + this.chunkSize + this.offset + ); + reader.onload = (e) => { + if (e.target.error === null) { + this.offset += /** @type {ArrayBuffer} */ + e.target.result.byteLength; + this.pushChunk( + /** @type {ArrayBuffer} */ + e.target.result + ); + } else { + return logError("Read error: " + e.target.error); + } + }; + reader.readAsArrayBuffer(blob); + } + pushChunk(chunk) { + if (!this.uploadChannel.isJoined()) { + return; + } + this.uploadChannel.push("chunk", chunk, this.chunkTimeout).receive("ok", () => { + this.entry.progress(this.offset / this.entry.file.size * 100); + if (!this.isDone()) { + this.chunkTimer = setTimeout( + () => this.readNextChunk(), + this.liveSocket.getLatencySim() || 0 + ); + } + }).receive("error", ({ reason }) => this.error(reason)); + } + }; + var logError = (msg, obj) => console.error && console.error(msg, obj); + var isCid = (cid) => { + const type = typeof cid; + return type === "number" || type === "string" && /^(0|[1-9]\d*)$/.test(cid); + }; + function detectDuplicateIds() { + const ids = /* @__PURE__ */ new Set(); + const elems = document.querySelectorAll("*[id]"); + for (let i = 0, len = elems.length; i < len; i++) { + if (ids.has(elems[i].id)) { + console.error( + `Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.` + ); + } else { + ids.add(elems[i].id); + } + } + } + function detectInvalidStreamInserts(inserts) { + const errors = /* @__PURE__ */ new Set(); + Object.keys(inserts).forEach((id) => { + const streamEl = document.getElementById(id); + if (streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream") { + errors.add( + `The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.` + ); + } + }); + errors.forEach((error) => console.error(error)); + } + var debug = (view, kind, msg, obj) => { + if (view.liveSocket.isDebugEnabled()) { + console.log(`${view.id} ${kind}: ${msg} - `, obj); + } + }; + var closure2 = (val) => typeof val === "function" ? val : function() { + return val; + }; + var clone = (obj) => { + return JSON.parse(JSON.stringify(obj)); + }; + var closestPhxBinding = (el, binding, borderEl) => { + do { + if (el.matches(`[${binding}]`) && !el.disabled) { + return el; + } + el = el.parentElement || el.parentNode; + } while (el !== null && el.nodeType === 1 && !(borderEl && borderEl.isSameNode(el) || el.matches(PHX_VIEW_SELECTOR))); + return null; + }; + var isObject = (obj) => { + return obj !== null && typeof obj === "object" && !(obj instanceof Array); + }; + var isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2); + var isEmpty = (obj) => { + for (const x in obj) { + return false; + } + return true; + }; + var maybe = (el, callback) => el && callback(el); + var channelUploader = function(entries, onError, resp, liveSocket2) { + entries.forEach((entry) => { + const entryUploader = new EntryUploader(entry, resp.config, liveSocket2); + entryUploader.upload(); + }); + }; + var eventContainsFiles = (e) => { + if (e.dataTransfer.types) { + for (let i = 0; i < e.dataTransfer.types.length; i++) { + if (e.dataTransfer.types[i] === "Files") { + return true; + } + } + } + return false; + }; + var Browser = { + canPushState() { + return typeof history.pushState !== "undefined"; + }, + dropLocal(localStorage, namespace, subkey) { + return localStorage.removeItem(this.localKey(namespace, subkey)); + }, + updateLocal(localStorage, namespace, subkey, initial, func) { + const current = this.getLocal(localStorage, namespace, subkey); + const key = this.localKey(namespace, subkey); + const newVal = current === null ? initial : func(current); + localStorage.setItem(key, JSON.stringify(newVal)); + return newVal; + }, + getLocal(localStorage, namespace, subkey) { + return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))); + }, + updateCurrentState(callback) { + if (!this.canPushState()) { + return; + } + history.replaceState( + callback(history.state || {}), + "", + window.location.href + ); + }, + pushState(kind, meta, to) { + if (this.canPushState()) { + if (to !== window.location.href) { + if (meta.type == "redirect" && meta.scroll) { + const currentState = history.state || {}; + currentState.scroll = meta.scroll; + history.replaceState(currentState, "", window.location.href); + } + delete meta.scroll; + history[kind + "State"](meta, "", to || null); + window.requestAnimationFrame(() => { + const hashEl = this.getHashTargetEl(window.location.hash); + if (hashEl) { + hashEl.scrollIntoView(); + } else if (meta.type === "redirect") { + window.scroll(0, 0); + } + }); + } + } else { + this.redirect(to); + } + }, + setCookie(name, value, maxAgeSeconds) { + const expires = typeof maxAgeSeconds === "number" ? ` max-age=${maxAgeSeconds};` : ""; + document.cookie = `${name}=${value};${expires} path=/`; + }, + getCookie(name) { + return document.cookie.replace( + new RegExp(`(?:(?:^|.*;s*)${name}s*=s*([^;]*).*$)|^.*$`), + "$1" + ); + }, + deleteCookie(name) { + document.cookie = `${name}=; max-age=-1; path=/`; + }, + redirect(toURL, flash, navigate = (url) => { + window.location.href = url; + }) { + if (flash) { + this.setCookie("__phoenix_flash__", flash, 60); + } + navigate(toURL); + }, + localKey(namespace, subkey) { + return `${namespace}-${subkey}`; + }, + getHashTargetEl(maybeHash) { + const hash = maybeHash.toString().substring(1); + if (hash === "") { + return; + } + return document.getElementById(hash) || document.querySelector(`a[name="${hash}"]`); + } + }; + var browser_default = Browser; + var DOM = { + byId(id) { + return document.getElementById(id) || logError(`no id found for ${id}`); + }, + removeClass(el, className) { + el.classList.remove(className); + if (el.classList.length === 0) { + el.removeAttribute("class"); + } + }, + all(node, query, callback) { + if (!node) { + return []; + } + const array = Array.from(node.querySelectorAll(query)); + if (callback) { + array.forEach(callback); + } + return array; + }, + childNodeLength(html) { + const template = document.createElement("template"); + template.innerHTML = html; + return template.content.childElementCount; + }, + isUploadInput(el) { + return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; + }, + isAutoUpload(inputEl) { + return inputEl.hasAttribute("data-phx-auto-upload"); + }, + findUploadInputs(node) { + const formId = node.id; + const inputsOutsideForm = this.all( + document, + `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]` + ); + return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat( + inputsOutsideForm + ); + }, + findComponentNodeList(viewId, cid, doc2 = document) { + return this.all( + doc2, + `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]` + ); + }, + isPhxDestroyed(node) { + return node.id && DOM.private(node, "destroyed") ? true : false; + }, + wantsNewTab(e) { + const wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || e.button && e.button === 1; + const isDownload = e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"); + const isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank"; + const isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_"); + return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab; + }, + isUnloadableFormSubmit(e) { + const isDialogSubmit = e.target && e.target.getAttribute("method") === "dialog" || e.submitter && e.submitter.getAttribute("formmethod") === "dialog"; + if (isDialogSubmit) { + return false; + } else { + return !e.defaultPrevented && !this.wantsNewTab(e); + } + }, + isNewPageClick(e, currentLocation) { + const href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null; + let url; + if (e.defaultPrevented || href === null || this.wantsNewTab(e)) { + return false; + } + if (href.startsWith("mailto:") || href.startsWith("tel:")) { + return false; + } + if (e.target.isContentEditable) { + return false; + } + try { + url = new URL(href); + } catch { + try { + url = new URL(href, currentLocation); + } catch { + return true; + } + } + if (url.host === currentLocation.host && url.protocol === currentLocation.protocol) { + if (url.pathname === currentLocation.pathname && url.search === currentLocation.search) { + return url.hash === "" && !url.href.endsWith("#"); + } + } + return url.protocol.startsWith("http"); + }, + markPhxChildDestroyed(el) { + if (this.isPhxChild(el)) { + el.setAttribute(PHX_SESSION, ""); + } + this.putPrivate(el, "destroyed", true); + }, + findPhxChildrenInFragment(html, parentId) { + const template = document.createElement("template"); + template.innerHTML = html; + return this.findPhxChildren(template.content, parentId); + }, + isIgnored(el, phxUpdate) { + return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore"; + }, + isPhxUpdate(el, phxUpdate, updateTypes) { + return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0; + }, + findPhxSticky(el) { + return this.all(el, `[${PHX_STICKY}]`); + }, + findPhxChildren(el, parentId) { + return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`); + }, + findExistingParentCIDs(viewId, cids) { + const parentCids = /* @__PURE__ */ new Set(); + const childrenCids = /* @__PURE__ */ new Set(); + cids.forEach((cid) => { + this.all( + document, + `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]` + ).forEach((parent) => { + parentCids.add(cid); + this.all(parent, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}]`).map((el) => parseInt(el.getAttribute(PHX_COMPONENT))).forEach((childCID) => childrenCids.add(childCID)); + }); + }); + childrenCids.forEach((childCid) => parentCids.delete(childCid)); + return parentCids; + }, + private(el, key) { + return el[PHX_PRIVATE] && el[PHX_PRIVATE][key]; + }, + deletePrivate(el, key) { + el[PHX_PRIVATE] && delete el[PHX_PRIVATE][key]; + }, + putPrivate(el, key, value) { + if (!el[PHX_PRIVATE]) { + el[PHX_PRIVATE] = {}; + } + el[PHX_PRIVATE][key] = value; + }, + updatePrivate(el, key, defaultVal, updateFunc) { + const existing = this.private(el, key); + if (existing === void 0) { + this.putPrivate(el, key, updateFunc(defaultVal)); + } else { + this.putPrivate(el, key, updateFunc(existing)); + } + }, + syncPendingAttrs(fromEl, toEl) { + if (!fromEl.hasAttribute(PHX_REF_SRC)) { + return; + } + PHX_EVENT_CLASSES.forEach((className) => { + fromEl.classList.contains(className) && toEl.classList.add(className); + }); + PHX_PENDING_ATTRS.filter((attr) => fromEl.hasAttribute(attr)).forEach( + (attr) => { + toEl.setAttribute(attr, fromEl.getAttribute(attr)); + } + ); + }, + copyPrivates(target, source) { + if (source[PHX_PRIVATE]) { + target[PHX_PRIVATE] = source[PHX_PRIVATE]; + } + }, + putTitle(str) { + const titleEl = document.querySelector("title"); + if (titleEl) { + const { prefix, suffix, default: defaultTitle } = titleEl.dataset; + const isEmpty2 = typeof str !== "string" || str.trim() === ""; + if (isEmpty2 && typeof defaultTitle !== "string") { + return; + } + const inner = isEmpty2 ? defaultTitle : str; + document.title = `${prefix || ""}${inner || ""}${suffix || ""}`; + } else { + document.title = str; + } + }, + debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback) { + let debounce = el.getAttribute(phxDebounce); + let throttle = el.getAttribute(phxThrottle); + if (debounce === "") { + debounce = defaultDebounce; + } + if (throttle === "") { + throttle = defaultThrottle; + } + const value = debounce || throttle; + switch (value) { + case null: + return callback(); + case "blur": + this.incCycle(el, "debounce-blur-cycle", () => { + if (asyncFilter()) { + callback(); + } + }); + if (this.once(el, "debounce-blur")) { + el.addEventListener( + "blur", + () => this.triggerCycle(el, "debounce-blur-cycle") + ); + } + return; + default: + const timeout = parseInt(value); + const trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback(); + const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger); + if (isNaN(timeout)) { + return logError(`invalid throttle/debounce value: ${value}`); + } + if (throttle) { + let newKeyDown = false; + if (event.type === "keydown") { + const prevKey = this.private(el, DEBOUNCE_PREV_KEY); + this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key); + newKeyDown = prevKey !== event.key; + } + if (!newKeyDown && this.private(el, THROTTLED)) { + return false; + } else { + callback(); + const t = setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER); + } + }, timeout); + this.putPrivate(el, THROTTLED, t); + } + } else { + setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle); + } + }, timeout); + } + const form = el.form; + if (form && this.once(form, "bind-debounce")) { + form.addEventListener("submit", () => { + Array.from(new FormData(form).entries(), ([name]) => { + const namedItem = form.elements.namedItem(name); + const input = namedItem instanceof RadioNodeList ? namedItem[0] : namedItem; + if (input) { + this.incCycle(input, DEBOUNCE_TRIGGER); + this.deletePrivate(input, THROTTLED); + } + }); + }); + } + if (this.once(el, "bind-debounce")) { + el.addEventListener("blur", () => { + clearTimeout(this.private(el, THROTTLED)); + this.triggerCycle(el, DEBOUNCE_TRIGGER); + }); + } + } + }, + triggerCycle(el, key, currentCycle) { + const [cycle, trigger] = this.private(el, key); + if (!currentCycle) { + currentCycle = cycle; + } + if (currentCycle === cycle) { + this.incCycle(el, key); + trigger(); + } + }, + once(el, key) { + if (this.private(el, key) === true) { + return false; + } + this.putPrivate(el, key, true); + return true; + }, + incCycle(el, key, trigger = function() { + }) { + let [currentCycle] = this.private(el, key) || [0, trigger]; + currentCycle++; + this.putPrivate(el, key, [currentCycle, trigger]); + return currentCycle; + }, + // maintains or adds privately used hook information + // fromEl and toEl can be the same element in the case of a newly added node + // fromEl and toEl can be any HTML node type, so we need to check if it's an element node + maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) { + if (fromEl.hasAttribute && fromEl.hasAttribute("data-phx-hook") && !toEl.hasAttribute("data-phx-hook")) { + toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")); + } + if (toEl.hasAttribute && (toEl.hasAttribute(phxViewportTop) || toEl.hasAttribute(phxViewportBottom))) { + toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll"); + } + }, + putCustomElHook(el, hook) { + if (el.isConnected) { + el.setAttribute("data-phx-hook", ""); + } else { + console.error(` + hook attached to non-connected DOM element + ensure you are calling createHook within your connectedCallback. ${el.outerHTML} + `); + } + this.putPrivate(el, "custom-el-hook", hook); + }, + getCustomElHook(el) { + return this.private(el, "custom-el-hook"); + }, + isUsedInput(el) { + return el.nodeType === Node.ELEMENT_NODE && (this.private(el, PHX_HAS_FOCUSED) || this.private(el, PHX_HAS_SUBMITTED)); + }, + resetForm(form) { + Array.from(form.elements).forEach((input) => { + this.deletePrivate(input, PHX_HAS_FOCUSED); + this.deletePrivate(input, PHX_HAS_SUBMITTED); + }); + }, + isPhxChild(node) { + return node.getAttribute && node.getAttribute(PHX_PARENT_ID); + }, + isPhxSticky(node) { + return node.getAttribute && node.getAttribute(PHX_STICKY) !== null; + }, + isChildOfAny(el, parents) { + return !!parents.find((parent) => parent.contains(el)); + }, + firstPhxChild(el) { + return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]; + }, + isPortalTemplate(el) { + return el.tagName === "TEMPLATE" && el.hasAttribute(PHX_PORTAL); + }, + closestViewEl(el) { + const portalOrViewEl = el.closest( + `[${PHX_TELEPORTED_REF}],${PHX_VIEW_SELECTOR}` + ); + if (!portalOrViewEl) { + return null; + } + if (portalOrViewEl.hasAttribute(PHX_TELEPORTED_REF)) { + return this.byId(portalOrViewEl.getAttribute(PHX_TELEPORTED_REF)); + } else if (portalOrViewEl.hasAttribute(PHX_SESSION)) { + return portalOrViewEl; + } + return null; + }, + dispatchEvent(target, name, opts = {}) { + let defaultBubble = true; + const isUploadTarget = target.nodeName === "INPUT" && target.type === "file"; + if (isUploadTarget && name === "click") { + defaultBubble = false; + } + const bubbles = opts.bubbles === void 0 ? defaultBubble : !!opts.bubbles; + const eventOpts = { + bubbles, + cancelable: true, + detail: opts.detail || {} + }; + const event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts); + target.dispatchEvent(event); + }, + cloneNode(node, html) { + if (typeof html === "undefined") { + return node.cloneNode(true); + } else { + const cloned = node.cloneNode(false); + cloned.innerHTML = html; + return cloned; + } + }, + // merge attributes from source to target + // if an element is ignored, we only merge data attributes + // including removing data attributes that are no longer in the source + mergeAttrs(target, source, opts = {}) { + const exclude = new Set(opts.exclude || []); + const isIgnored = opts.isIgnored; + const sourceAttrs = source.attributes; + for (let i = sourceAttrs.length - 1; i >= 0; i--) { + const name = sourceAttrs[i].name; + if (!exclude.has(name)) { + const sourceValue = source.getAttribute(name); + if (target.getAttribute(name) !== sourceValue && (!isIgnored || isIgnored && name.startsWith("data-"))) { + target.setAttribute(name, sourceValue); + } + } else { + if (name === "value") { + const sourceValue = source.value ?? source.getAttribute(name); + if (target.value === sourceValue) { + target.setAttribute("value", source.getAttribute(name)); + } + } + } + } + const targetAttrs = target.attributes; + for (let i = targetAttrs.length - 1; i >= 0; i--) { + const name = targetAttrs[i].name; + if (isIgnored) { + if (name.startsWith("data-") && !source.hasAttribute(name) && !PHX_PENDING_ATTRS.includes(name)) { + target.removeAttribute(name); + } + } else { + if (!source.hasAttribute(name)) { + target.removeAttribute(name); + } + } + } + }, + mergeFocusedInput(target, source) { + if (!(target instanceof HTMLSelectElement)) { + DOM.mergeAttrs(target, source, { exclude: ["value"] }); + } + if (source.readOnly) { + target.setAttribute("readonly", true); + } else { + target.removeAttribute("readonly"); + } + }, + hasSelectionRange(el) { + return el.setSelectionRange && (el.type === "text" || el.type === "textarea"); + }, + restoreFocus(focused, selectionStart, selectionEnd) { + if (focused instanceof HTMLSelectElement) { + focused.focus(); + } + if (!DOM.isTextualInput(focused)) { + return; + } + const wasFocused = focused.matches(":focus"); + if (!wasFocused) { + focused.focus(); + } + if (this.hasSelectionRange(focused)) { + focused.setSelectionRange(selectionStart, selectionEnd); + } + }, + isFormInput(el) { + if (el.localName && customElements.get(el.localName)) { + return customElements.get(el.localName)[`formAssociated`]; + } + return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button"; + }, + syncAttrsToProps(el) { + if (el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0) { + el.checked = el.getAttribute("checked") !== null; + } + }, + isTextualInput(el) { + return FOCUSABLE_INPUTS.indexOf(el.type) >= 0; + }, + isNowTriggerFormExternal(el, phxTriggerExternal) { + return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null && document.body.contains(el); + }, + cleanChildNodes(container, phxUpdate) { + if (DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend", PHX_STREAM])) { + const toRemove = []; + container.childNodes.forEach((childNode) => { + if (!childNode.id) { + const isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === ""; + if (!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE) { + logError( + `only HTML element tags with an id are allowed inside containers with phx-update. + +removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" + +` + ); + } + toRemove.push(childNode); + } + }); + toRemove.forEach((childNode) => childNode.remove()); + } + }, + replaceRootContainer(container, tagName, attrs) { + const retainedAttrs = /* @__PURE__ */ new Set([ + "id", + PHX_SESSION, + PHX_STATIC, + PHX_MAIN, + PHX_ROOT_ID + ]); + if (container.tagName.toLowerCase() === tagName.toLowerCase()) { + Array.from(container.attributes).filter((attr) => !retainedAttrs.has(attr.name.toLowerCase())).forEach((attr) => container.removeAttribute(attr.name)); + Object.keys(attrs).filter((name) => !retainedAttrs.has(name.toLowerCase())).forEach((attr) => container.setAttribute(attr, attrs[attr])); + return container; + } else { + const newContainer = document.createElement(tagName); + Object.keys(attrs).forEach( + (attr) => newContainer.setAttribute(attr, attrs[attr]) + ); + retainedAttrs.forEach( + (attr) => newContainer.setAttribute(attr, container.getAttribute(attr)) + ); + newContainer.innerHTML = container.innerHTML; + container.replaceWith(newContainer); + return newContainer; + } + }, + getSticky(el, name, defaultVal) { + const op = (DOM.private(el, "sticky") || []).find( + ([existingName]) => name === existingName + ); + if (op) { + const [_name, _op, stashedResult] = op; + return stashedResult; + } else { + return typeof defaultVal === "function" ? defaultVal() : defaultVal; + } + }, + deleteSticky(el, name) { + this.updatePrivate(el, "sticky", [], (ops) => { + return ops.filter(([existingName, _]) => existingName !== name); + }); + }, + putSticky(el, name, op) { + const stashedResult = op(el); + this.updatePrivate(el, "sticky", [], (ops) => { + const existingIndex = ops.findIndex( + ([existingName]) => name === existingName + ); + if (existingIndex >= 0) { + ops[existingIndex] = [name, op, stashedResult]; + } else { + ops.push([name, op, stashedResult]); + } + return ops; + }); + }, + applyStickyOperations(el) { + const ops = DOM.private(el, "sticky"); + if (!ops) { + return; + } + ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)); + }, + isLocked(el) { + return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK); + }, + attributeIgnored(attribute, ignoredAttributes) { + return ignoredAttributes.some( + (toIgnore) => attribute.name == toIgnore || toIgnore === "*" || toIgnore.includes("*") && attribute.name.match(toIgnore) != null + ); + } + }; + var dom_default = DOM; + var UploadEntry = class { + static isActive(fileEl, file) { + const isNew = file._phxRef === void 0; + const activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + const isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return file.size > 0 && (isNew || isActive); + } + static isPreflighted(fileEl, file) { + const preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(","); + const isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return isPreflighted && this.isActive(fileEl, file); + } + static isPreflightInProgress(file) { + return file._preflightInProgress === true; + } + static markPreflightInProgress(file) { + file._preflightInProgress = true; + } + constructor(fileEl, file, view, autoUpload) { + this.ref = LiveUploader.genFileRef(file); + this.fileEl = fileEl; + this.file = file; + this.view = view; + this.meta = null; + this._isCancelled = false; + this._isDone = false; + this._progress = 0; + this._lastProgressSent = -1; + this._onDone = function() { + }; + this._onElUpdated = this.onElUpdated.bind(this); + this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + this.autoUpload = autoUpload; + } + metadata() { + return this.meta; + } + progress(progress) { + this._progress = Math.floor(progress); + if (this._progress > this._lastProgressSent) { + if (this._progress >= 100) { + this._progress = 100; + this._lastProgressSent = 100; + this._isDone = true; + this.view.pushFileProgress(this.fileEl, this.ref, 100, () => { + LiveUploader.untrackFile(this.fileEl, this.file); + this._onDone(); + }); + } else { + this._lastProgressSent = this._progress; + this.view.pushFileProgress(this.fileEl, this.ref, this._progress); + } + } + } + isCancelled() { + return this._isCancelled; + } + cancel() { + this.file._preflightInProgress = false; + this._isCancelled = true; + this._isDone = true; + this._onDone(); + } + isDone() { + return this._isDone; + } + error(reason = "failed") { + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + this.view.pushFileProgress(this.fileEl, this.ref, { error: reason }); + if (!this.isAutoUpload()) { + LiveUploader.clearFiles(this.fileEl); + } + } + isAutoUpload() { + return this.autoUpload; + } + //private + onDone(callback) { + this._onDone = () => { + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + callback(); + }; + } + onElUpdated() { + const activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + if (activeRefs.indexOf(this.ref) === -1) { + LiveUploader.untrackFile(this.fileEl, this.file); + this.cancel(); + } + } + toPreflightPayload() { + return { + last_modified: this.file.lastModified, + name: this.file.name, + relative_path: this.file.webkitRelativePath, + size: this.file.size, + type: this.file.type, + ref: this.ref, + meta: typeof this.file.meta === "function" ? this.file.meta() : void 0 + }; + } + uploader(uploaders) { + if (this.meta.uploader) { + const callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`); + return { name: this.meta.uploader, callback }; + } else { + return { name: "channel", callback: channelUploader }; + } + } + zipPostFlight(resp) { + this.meta = resp.entries[this.ref]; + if (!this.meta) { + logError(`no preflight upload response returned with ref ${this.ref}`, { + input: this.fileEl, + response: resp + }); + } + } + }; + var liveUploaderFileRef = 0; + var LiveUploader = class _LiveUploader { + static genFileRef(file) { + const ref = file._phxRef; + if (ref !== void 0) { + return ref; + } else { + file._phxRef = (liveUploaderFileRef++).toString(); + return file._phxRef; + } + } + static getEntryDataURL(inputEl, ref, callback) { + const file = this.activeFiles(inputEl).find( + (file2) => this.genFileRef(file2) === ref + ); + callback(URL.createObjectURL(file)); + } + static hasUploadsInProgress(formEl) { + let active = 0; + dom_default.findUploadInputs(formEl).forEach((input) => { + if (input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)) { + active++; + } + }); + return active > 0; + } + static serializeUploads(inputEl) { + const files = this.activeFiles(inputEl); + const fileData = {}; + files.forEach((file) => { + const entry = { path: inputEl.name }; + const uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF); + fileData[uploadRef] = fileData[uploadRef] || []; + entry.ref = this.genFileRef(file); + entry.last_modified = file.lastModified; + entry.name = file.name || entry.ref; + entry.relative_path = file.webkitRelativePath; + entry.type = file.type; + entry.size = file.size; + if (typeof file.meta === "function") { + entry.meta = file.meta(); + } + fileData[uploadRef].push(entry); + }); + return fileData; + } + static clearFiles(inputEl) { + inputEl.value = null; + inputEl.removeAttribute(PHX_UPLOAD_REF); + dom_default.putPrivate(inputEl, "files", []); + } + static untrackFile(inputEl, file) { + dom_default.putPrivate( + inputEl, + "files", + dom_default.private(inputEl, "files").filter((f) => !Object.is(f, file)) + ); + } + /** + * @param {HTMLInputElement} inputEl + * @param {Array} files + * @param {DataTransfer} [dataTransfer] + */ + static trackFiles(inputEl, files, dataTransfer) { + if (inputEl.getAttribute("multiple") !== null) { + const newFiles = files.filter( + (file) => !this.activeFiles(inputEl).find((f) => Object.is(f, file)) + ); + dom_default.updatePrivate( + inputEl, + "files", + [], + (existing) => existing.concat(newFiles) + ); + inputEl.value = null; + } else { + if (dataTransfer && dataTransfer.files.length > 0) { + inputEl.files = dataTransfer.files; + } + dom_default.putPrivate(inputEl, "files", files); + } + } + static activeFileInputs(formEl) { + const fileInputs = dom_default.findUploadInputs(formEl); + return Array.from(fileInputs).filter( + (el) => el.files && this.activeFiles(el).length > 0 + ); + } + static activeFiles(input) { + return (dom_default.private(input, "files") || []).filter( + (f) => UploadEntry.isActive(input, f) + ); + } + static inputsAwaitingPreflight(formEl) { + const fileInputs = dom_default.findUploadInputs(formEl); + return Array.from(fileInputs).filter( + (input) => this.filesAwaitingPreflight(input).length > 0 + ); + } + static filesAwaitingPreflight(input) { + return this.activeFiles(input).filter( + (f) => !UploadEntry.isPreflighted(input, f) && !UploadEntry.isPreflightInProgress(f) + ); + } + static markPreflightInProgress(entries) { + entries.forEach((entry) => UploadEntry.markPreflightInProgress(entry.file)); + } + constructor(inputEl, view, onComplete) { + this.autoUpload = dom_default.isAutoUpload(inputEl); + this.view = view; + this.onComplete = onComplete; + this._entries = Array.from( + _LiveUploader.filesAwaitingPreflight(inputEl) || [] + ).map((file) => new UploadEntry(inputEl, file, view, this.autoUpload)); + _LiveUploader.markPreflightInProgress(this._entries); + this.numEntriesInProgress = this._entries.length; + } + isAutoUpload() { + return this.autoUpload; + } + entries() { + return this._entries; + } + initAdapterUpload(resp, onError, liveSocket2) { + this._entries = this._entries.map((entry) => { + if (entry.isCancelled()) { + this.numEntriesInProgress--; + if (this.numEntriesInProgress === 0) { + this.onComplete(); + } + } else { + entry.zipPostFlight(resp); + entry.onDone(() => { + this.numEntriesInProgress--; + if (this.numEntriesInProgress === 0) { + this.onComplete(); + } + }); + } + return entry; + }); + const groupedEntries = this._entries.reduce((acc, entry) => { + if (!entry.meta) { + return acc; + } + const { name, callback } = entry.uploader(liveSocket2.uploaders); + acc[name] = acc[name] || { callback, entries: [] }; + acc[name].entries.push(entry); + return acc; + }, {}); + for (const name in groupedEntries) { + const { callback, entries } = groupedEntries[name]; + callback(entries, onError, resp, liveSocket2); + } + } + }; + var ARIA = { + anyOf(instance, classes) { + return classes.find((name) => instance instanceof name); + }, + isFocusable(el, interactiveOnly) { + return el instanceof HTMLAnchorElement && el.rel !== "ignore" || el instanceof HTMLAreaElement && el.href !== void 0 || !el.disabled && this.anyOf(el, [ + HTMLInputElement, + HTMLSelectElement, + HTMLTextAreaElement, + HTMLButtonElement + ]) || el instanceof HTMLIFrameElement || el.tabIndex >= 0 && el.getAttribute("aria-hidden") !== "true" || !interactiveOnly && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"; + }, + attemptFocus(el, interactiveOnly) { + if (this.isFocusable(el, interactiveOnly)) { + try { + el.focus(); + } catch { + } + } + return !!document.activeElement && document.activeElement.isSameNode(el); + }, + focusFirstInteractive(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child, true) || this.focusFirstInteractive(child)) { + return true; + } + child = child.nextElementSibling; + } + }, + focusFirst(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusFirst(child)) { + return true; + } + child = child.nextElementSibling; + } + }, + focusLast(el) { + let child = el.lastElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusLast(child)) { + return true; + } + child = child.previousElementSibling; + } + } + }; + var aria_default = ARIA; + var Hooks = { + LiveFileUpload: { + activeRefs() { + return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS); + }, + preflightedRefs() { + return this.el.getAttribute(PHX_PREFLIGHTED_REFS); + }, + mounted() { + this.js().ignoreAttributes(this.el, ["value"]); + this.preflightedWas = this.preflightedRefs(); + }, + updated() { + const newPreflights = this.preflightedRefs(); + if (this.preflightedWas !== newPreflights) { + this.preflightedWas = newPreflights; + if (newPreflights === "") { + this.__view().cancelSubmit(this.el.form); + } + } + if (this.activeRefs() === "") { + this.el.value = null; + } + this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED)); + } + }, + LiveImgPreview: { + mounted() { + this.ref = this.el.getAttribute("data-phx-entry-ref"); + this.inputEl = document.getElementById( + this.el.getAttribute(PHX_UPLOAD_REF) + ); + LiveUploader.getEntryDataURL(this.inputEl, this.ref, (url) => { + this.url = url; + this.el.src = url; + }); + }, + destroyed() { + URL.revokeObjectURL(this.url); + } + }, + FocusWrap: { + mounted() { + this.focusStart = this.el.firstElementChild; + this.focusEnd = this.el.lastElementChild; + this.focusStart.addEventListener("focus", (e) => { + if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { + const nextFocus = e.target.nextElementSibling; + aria_default.attemptFocus(nextFocus) || aria_default.focusFirst(nextFocus); + } else { + aria_default.focusLast(this.el); + } + }); + this.focusEnd.addEventListener("focus", (e) => { + if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { + const nextFocus = e.target.previousElementSibling; + aria_default.attemptFocus(nextFocus) || aria_default.focusLast(nextFocus); + } else { + aria_default.focusFirst(this.el); + } + }); + if (!this.el.contains(document.activeElement)) { + this.el.addEventListener("phx:show-end", () => this.el.focus()); + if (window.getComputedStyle(this.el).display !== "none") { + aria_default.focusFirst(this.el); + } + } + } + } + }; + var findScrollContainer = (el) => { + if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) + return null; + if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) + return el; + return findScrollContainer(el.parentElement); + }; + var scrollTop = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.scrollTop; + } else { + return document.documentElement.scrollTop || document.body.scrollTop; + } + }; + var bottom = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().bottom; + } else { + return window.innerHeight || document.documentElement.clientHeight; + } + }; + var top = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().top; + } else { + return 0; + } + }; + var isAtViewportTop = (el, scrollContainer) => { + const rect = el.getBoundingClientRect(); + return Math.ceil(rect.top) >= top(scrollContainer) && Math.floor(rect.top) <= bottom(scrollContainer); + }; + var isAtViewportBottom = (el, scrollContainer) => { + const rect = el.getBoundingClientRect(); + return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.floor(rect.bottom) <= bottom(scrollContainer); + }; + var isWithinViewport = (el, scrollContainer) => { + const rect = el.getBoundingClientRect(); + return Math.ceil(rect.top) >= top(scrollContainer) && Math.floor(rect.top) <= bottom(scrollContainer); + }; + Hooks.InfiniteScroll = { + mounted() { + this.scrollContainer = findScrollContainer(this.el); + let scrollBefore = scrollTop(this.scrollContainer); + let topOverran = false; + const throttleInterval = 500; + let pendingOp = null; + const onTopOverrun = this.throttle( + throttleInterval, + (topEvent, firstChild) => { + pendingOp = () => true; + this.liveSocket.js().push(this.el, topEvent, { + value: { id: firstChild.id, _overran: true }, + callback: () => { + pendingOp = null; + } + }); + } + ); + const onFirstChildAtTop = this.throttle( + throttleInterval, + (topEvent, firstChild) => { + pendingOp = () => firstChild.scrollIntoView({ block: "start" }); + this.liveSocket.js().push(this.el, topEvent, { + value: { id: firstChild.id }, + callback: () => { + pendingOp = null; + window.requestAnimationFrame(() => { + if (!isWithinViewport(firstChild, this.scrollContainer)) { + firstChild.scrollIntoView({ block: "start" }); + } + }); + } + }); + } + ); + const onLastChildAtBottom = this.throttle( + throttleInterval, + (bottomEvent, lastChild) => { + pendingOp = () => lastChild.scrollIntoView({ block: "end" }); + this.liveSocket.js().push(this.el, bottomEvent, { + value: { id: lastChild.id }, + callback: () => { + pendingOp = null; + window.requestAnimationFrame(() => { + if (!isWithinViewport(lastChild, this.scrollContainer)) { + lastChild.scrollIntoView({ block: "end" }); + } + }); + } + }); + } + ); + this.onScroll = (_e) => { + const scrollNow = scrollTop(this.scrollContainer); + if (pendingOp) { + scrollBefore = scrollNow; + return pendingOp(); + } + const rect = this.findOverrunTarget(); + const topEvent = this.el.getAttribute( + this.liveSocket.binding("viewport-top") + ); + const bottomEvent = this.el.getAttribute( + this.liveSocket.binding("viewport-bottom") + ); + const lastChild = this.el.lastElementChild; + const firstChild = this.el.firstElementChild; + const isScrollingUp = scrollNow < scrollBefore; + const isScrollingDown = scrollNow > scrollBefore; + if (isScrollingUp && topEvent && !topOverran && rect.top >= 0) { + topOverran = true; + onTopOverrun(topEvent, firstChild); + } else if (isScrollingDown && topOverran && rect.top <= 0) { + topOverran = false; + } + if (topEvent && isScrollingUp && isAtViewportTop(firstChild, this.scrollContainer)) { + onFirstChildAtTop(topEvent, firstChild); + } else if (bottomEvent && isScrollingDown && isAtViewportBottom(lastChild, this.scrollContainer)) { + onLastChildAtBottom(bottomEvent, lastChild); + } + scrollBefore = scrollNow; + }; + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.onScroll); + } else { + window.addEventListener("scroll", this.onScroll); + } + }, + updated() { + if (this.scrollContainer && !this.scrollContainer.isConnected) { + this.destroyed(); + this.mounted(); + } + }, + destroyed() { + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.onScroll); + } else { + window.removeEventListener("scroll", this.onScroll); + } + }, + throttle(interval, callback) { + let lastCallAt = 0; + let timer; + return (...args) => { + const now = Date.now(); + const remainingTime = interval - (now - lastCallAt); + if (remainingTime <= 0 || remainingTime > interval) { + if (timer) { + clearTimeout(timer); + timer = null; + } + lastCallAt = now; + callback(...args); + } else if (!timer) { + timer = setTimeout(() => { + lastCallAt = Date.now(); + timer = null; + callback(...args); + }, remainingTime); + } + }; + }, + findOverrunTarget() { + let rect; + const overrunTarget = this.el.getAttribute( + this.liveSocket.binding(PHX_VIEWPORT_OVERRUN_TARGET) + ); + if (overrunTarget) { + const overrunEl = document.getElementById(overrunTarget); + if (overrunEl) { + rect = overrunEl.getBoundingClientRect(); + } else { + throw new Error("did not find element with id " + overrunTarget); + } + } else { + rect = this.el.getBoundingClientRect(); + } + return rect; + } + }; + var hooks_default = Hooks; + var ElementRef = class { + static onUnlock(el, callback) { + if (!dom_default.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)) { + return callback(); + } + const closestLock = el.closest(`[${PHX_REF_LOCK}]`); + const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK); + closestLock.addEventListener( + `phx:undo-lock:${ref}`, + () => { + callback(); + }, + { once: true } + ); + } + constructor(el) { + this.el = el; + this.loadingRef = el.hasAttribute(PHX_REF_LOADING) ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) : null; + this.lockRef = el.hasAttribute(PHX_REF_LOCK) ? parseInt(el.getAttribute(PHX_REF_LOCK), 10) : null; + } + // public + maybeUndo(ref, phxEvent, eachCloneCallback) { + if (!this.isWithin(ref)) { + dom_default.updatePrivate(this.el, PHX_PENDING_REFS, [], (pendingRefs) => { + pendingRefs.push(ref); + return pendingRefs; + }); + return; + } + this.undoLocks(ref, phxEvent, eachCloneCallback); + this.undoLoading(ref, phxEvent); + dom_default.updatePrivate(this.el, PHX_PENDING_REFS, [], (pendingRefs) => { + return pendingRefs.filter((pendingRef) => { + let opts = { + detail: { ref: pendingRef, event: phxEvent }, + bubbles: true, + cancelable: false + }; + if (this.loadingRef && this.loadingRef > pendingRef) { + this.el.dispatchEvent( + new CustomEvent(`phx:undo-loading:${pendingRef}`, opts) + ); + } + if (this.lockRef && this.lockRef > pendingRef) { + this.el.dispatchEvent( + new CustomEvent(`phx:undo-lock:${pendingRef}`, opts) + ); + } + return pendingRef > ref; + }); + }); + if (this.isFullyResolvedBy(ref)) { + this.el.removeAttribute(PHX_REF_SRC); + } + } + // private + isWithin(ref) { + return !(this.loadingRef !== null && this.loadingRef > ref && this.lockRef !== null && this.lockRef > ref); + } + // Check for cloned PHX_REF_LOCK element that has been morphed behind + // the scenes while this element was locked in the DOM. + // When we apply the cloned tree to the active DOM element, we must + // + // 1. execute pending mounted hooks for nodes now in the DOM + // 2. undo any ref inside the cloned tree that has since been ack'd + undoLocks(ref, phxEvent, eachCloneCallback) { + if (!this.isLockUndoneBy(ref)) { + return; + } + const clonedTree = dom_default.private(this.el, PHX_REF_LOCK); + if (clonedTree) { + eachCloneCallback(clonedTree); + dom_default.deletePrivate(this.el, PHX_REF_LOCK); + } + this.el.removeAttribute(PHX_REF_LOCK); + const opts = { + detail: { ref, event: phxEvent }, + bubbles: true, + cancelable: false + }; + this.el.dispatchEvent( + new CustomEvent(`phx:undo-lock:${this.lockRef}`, opts) + ); + } + undoLoading(ref, phxEvent) { + if (!this.isLoadingUndoneBy(ref)) { + if (this.canUndoLoading(ref) && this.el.classList.contains("phx-submit-loading")) { + this.el.classList.remove("phx-change-loading"); + } + return; + } + if (this.canUndoLoading(ref)) { + this.el.removeAttribute(PHX_REF_LOADING); + const disabledVal = this.el.getAttribute(PHX_DISABLED); + const readOnlyVal = this.el.getAttribute(PHX_READONLY); + if (readOnlyVal !== null) { + this.el.readOnly = readOnlyVal === "true" ? true : false; + this.el.removeAttribute(PHX_READONLY); + } + if (disabledVal !== null) { + this.el.disabled = disabledVal === "true" ? true : false; + this.el.removeAttribute(PHX_DISABLED); + } + const disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE); + if (disableRestore !== null) { + this.el.textContent = disableRestore; + this.el.removeAttribute(PHX_DISABLE_WITH_RESTORE); + } + const opts = { + detail: { ref, event: phxEvent }, + bubbles: true, + cancelable: false + }; + this.el.dispatchEvent( + new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts) + ); + } + PHX_EVENT_CLASSES.forEach((name) => { + if (name !== "phx-submit-loading" || this.canUndoLoading(ref)) { + dom_default.removeClass(this.el, name); + } + }); + } + isLoadingUndoneBy(ref) { + return this.loadingRef === null ? false : this.loadingRef <= ref; + } + isLockUndoneBy(ref) { + return this.lockRef === null ? false : this.lockRef <= ref; + } + isFullyResolvedBy(ref) { + return (this.loadingRef === null || this.loadingRef <= ref) && (this.lockRef === null || this.lockRef <= ref); + } + // only remove the phx-submit-loading class if we are not locked + canUndoLoading(ref) { + return this.lockRef === null || this.lockRef <= ref; + } + }; + var DOMPostMorphRestorer = class { + constructor(containerBefore, containerAfter, updateType) { + const idsBefore = /* @__PURE__ */ new Set(); + const idsAfter = new Set( + [...containerAfter.children].map((child) => child.id) + ); + const elementsToModify = []; + Array.from(containerBefore.children).forEach((child) => { + if (child.id) { + idsBefore.add(child.id); + if (idsAfter.has(child.id)) { + const previousElementId = child.previousElementSibling && child.previousElementSibling.id; + elementsToModify.push({ + elementId: child.id, + previousElementId + }); + } + } + }); + this.containerId = containerAfter.id; + this.updateType = updateType; + this.elementsToModify = elementsToModify; + this.elementIdsToAdd = [...idsAfter].filter((id) => !idsBefore.has(id)); + } + // We do the following to optimize append/prepend operations: + // 1) Track ids of modified elements & of new elements + // 2) All the modified elements are put back in the correct position in the DOM tree + // by storing the id of their previous sibling + // 3) New elements are going to be put in the right place by morphdom during append. + // For prepend, we move them to the first position in the container + perform() { + const container = dom_default.byId(this.containerId); + if (!container) { + return; + } + this.elementsToModify.forEach((elementToModify) => { + if (elementToModify.previousElementId) { + maybe( + document.getElementById(elementToModify.previousElementId), + (previousElem) => { + maybe( + document.getElementById(elementToModify.elementId), + (elem) => { + const isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id; + if (!isInRightPlace) { + previousElem.insertAdjacentElement("afterend", elem); + } + } + ); + } + ); + } else { + maybe(document.getElementById(elementToModify.elementId), (elem) => { + const isInRightPlace = elem.previousElementSibling == null; + if (!isInRightPlace) { + container.insertAdjacentElement("afterbegin", elem); + } + }); + } + }); + if (this.updateType == "prepend") { + this.elementIdsToAdd.reverse().forEach((elemId) => { + maybe( + document.getElementById(elemId), + (elem) => container.insertAdjacentElement("afterbegin", elem) + ); + }); + } + } + }; + var DOCUMENT_FRAGMENT_NODE = 11; + function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + if (fromValue !== attrValue) { + if (attr.prefix === "xmlns") { + attrName = attr.name; + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + var fromNodeAttrs = fromNode.attributes; + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } + } + var range; + var NS_XHTML = "http://www.w3.org/1999/xhtml"; + var doc = typeof document === "undefined" ? void 0 : document; + var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); + var HAS_RANGE_SUPPORT = !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); + function createFragmentFromTemplate(str) { + var template = doc.createElement("template"); + template.innerHTML = str; + return template.content.childNodes[0]; + } + function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; + } + function createFragmentFromWrap(str) { + var fragment = doc.createElement("body"); + fragment.innerHTML = str; + return fragment.childNodes[0]; + } + function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + return createFragmentFromTemplate(str); + } else if (HAS_RANGE_SUPPORT) { + return createFragmentFromRange(str); + } + return createFragmentFromWrap(str); + } + function compareNodeNames(fromEl, toEl) { + var fromNodeName = fromEl.nodeName; + var toNodeName = toEl.nodeName; + var fromCodeStart, toCodeStart; + if (fromNodeName === toNodeName) { + return true; + } + fromCodeStart = fromNodeName.charCodeAt(0); + toCodeStart = toNodeName.charCodeAt(0); + if (fromCodeStart <= 90 && toCodeStart >= 97) { + return fromNodeName === toNodeName.toUpperCase(); + } else if (toCodeStart <= 90 && fromCodeStart >= 97) { + return toNodeName === fromNodeName.toUpperCase(); + } else { + return false; + } + } + function createElementNS(name, namespaceURI) { + return !namespaceURI || namespaceURI === NS_XHTML ? doc.createElement(name) : doc.createElementNS(namespaceURI, name); + } + function moveChildren(fromEl, toEl) { + var curChild = fromEl.firstChild; + while (curChild) { + var nextChild = curChild.nextSibling; + toEl.appendChild(curChild); + curChild = nextChild; + } + return toEl; + } + function syncBooleanAttrProp(fromEl, toEl, name) { + if (fromEl[name] !== toEl[name]) { + fromEl[name] = toEl[name]; + if (fromEl[name]) { + fromEl.setAttribute(name, ""); + } else { + fromEl.removeAttribute(name); + } + } + } + var specialElHandlers = { + OPTION: function(fromEl, toEl) { + var parentNode = fromEl.parentNode; + if (parentNode) { + var parentName = parentNode.nodeName.toUpperCase(); + if (parentName === "OPTGROUP") { + parentNode = parentNode.parentNode; + parentName = parentNode && parentNode.nodeName.toUpperCase(); + } + if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { + if (fromEl.hasAttribute("selected") && !toEl.selected) { + fromEl.setAttribute("selected", "selected"); + fromEl.removeAttribute("selected"); + } + parentNode.selectedIndex = -1; + } + } + syncBooleanAttrProp(fromEl, toEl, "selected"); + }, + /** + * The "value" attribute is special for the element since it sets + * the initial value. Changing the "value" attribute without changing the + * "value" property will have no effect since it is only used to the set the + * initial value. Similar for the "checked" attribute, and "disabled". + */ + INPUT: function(fromEl, toEl) { + syncBooleanAttrProp(fromEl, toEl, "checked"); + syncBooleanAttrProp(fromEl, toEl, "disabled"); + if (fromEl.value !== toEl.value) { + fromEl.value = toEl.value; + } + if (!toEl.hasAttribute("value")) { + fromEl.removeAttribute("value"); + } + }, + TEXTAREA: function(fromEl, toEl) { + var newValue = toEl.value; + if (fromEl.value !== newValue) { + fromEl.value = newValue; + } + var firstChild = fromEl.firstChild; + if (firstChild) { + var oldValue = firstChild.nodeValue; + if (oldValue == newValue || !newValue && oldValue == fromEl.placeholder) { + return; + } + firstChild.nodeValue = newValue; + } + }, + SELECT: function(fromEl, toEl) { + if (!toEl.hasAttribute("multiple")) { + var selectedIndex = -1; + var i = 0; + var curChild = fromEl.firstChild; + var optgroup; + var nodeName; + while (curChild) { + nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); + if (nodeName === "OPTGROUP") { + optgroup = curChild; + curChild = optgroup.firstChild; + if (!curChild) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } else { + if (nodeName === "OPTION") { + if (curChild.hasAttribute("selected")) { + selectedIndex = i; + break; + } + i++; + } + curChild = curChild.nextSibling; + if (!curChild && optgroup) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } + } + fromEl.selectedIndex = selectedIndex; + } + } + }; + var ELEMENT_NODE = 1; + var DOCUMENT_FRAGMENT_NODE$1 = 11; + var TEXT_NODE = 3; + var COMMENT_NODE = 8; + function noop() { + } + function defaultGetNodeKey(node) { + if (node) { + return node.getAttribute && node.getAttribute("id") || node.id; + } + } + function morphdomFactory(morphAttrs2) { + return function morphdom2(fromNode, toNode, options) { + if (!options) { + options = {}; + } + if (typeof toNode === "string") { + if (fromNode.nodeName === "#document" || fromNode.nodeName === "HTML") { + var toNodeHtml = toNode; + toNode = doc.createElement("html"); + toNode.innerHTML = toNodeHtml; + } else if (fromNode.nodeName === "BODY") { + var toNodeBody = toNode; + toNode = doc.createElement("html"); + toNode.innerHTML = toNodeBody; + var bodyElement = toNode.querySelector("body"); + if (bodyElement) { + toNode = bodyElement; + } + } else { + toNode = toElement(toNode); + } + } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + toNode = toNode.firstElementChild; + } + var getNodeKey = options.getNodeKey || defaultGetNodeKey; + var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; + var onNodeAdded = options.onNodeAdded || noop; + var onBeforeElUpdated = options.onBeforeElUpdated || noop; + var onElUpdated = options.onElUpdated || noop; + var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; + var onNodeDiscarded = options.onNodeDiscarded || noop; + var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; + var skipFromChildren = options.skipFromChildren || noop; + var addChild = options.addChild || function(parent, child) { + return parent.appendChild(child); + }; + var childrenOnly = options.childrenOnly === true; + var fromNodesLookup = /* @__PURE__ */ Object.create(null); + var keyedRemovalList = []; + function addKeyedRemoval(key) { + keyedRemovalList.push(key); + } + function walkDiscardedChildNodes(node, skipKeyedNodes) { + if (node.nodeType === ELEMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + var key = void 0; + if (skipKeyedNodes && (key = getNodeKey(curChild))) { + addKeyedRemoval(key); + } else { + onNodeDiscarded(curChild); + if (curChild.firstChild) { + walkDiscardedChildNodes(curChild, skipKeyedNodes); + } + } + curChild = curChild.nextSibling; + } + } + } + function removeNode(node, parentNode, skipKeyedNodes) { + if (onBeforeNodeDiscarded(node) === false) { + return; + } + if (parentNode) { + parentNode.removeChild(node); + } + onNodeDiscarded(node); + walkDiscardedChildNodes(node, skipKeyedNodes); + } + function indexTree(node) { + if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + var curChild = node.firstChild; + while (curChild) { + var key = getNodeKey(curChild); + if (key) { + fromNodesLookup[key] = curChild; + } + indexTree(curChild); + curChild = curChild.nextSibling; + } + } + } + indexTree(fromNode); + function handleNodeAdded(el) { + onNodeAdded(el); + var curChild = el.firstChild; + while (curChild) { + var nextSibling = curChild.nextSibling; + var key = getNodeKey(curChild); + if (key) { + var unmatchedFromEl = fromNodesLookup[key]; + if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { + curChild.parentNode.replaceChild(unmatchedFromEl, curChild); + morphEl(unmatchedFromEl, curChild); + } else { + handleNodeAdded(curChild); + } + } else { + handleNodeAdded(curChild); + } + curChild = nextSibling; + } + } + function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { + while (curFromNodeChild) { + var fromNextSibling = curFromNodeChild.nextSibling; + if (curFromNodeKey = getNodeKey(curFromNodeChild)) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = fromNextSibling; + } + } + function morphEl(fromEl, toEl, childrenOnly2) { + var toElKey = getNodeKey(toEl); + if (toElKey) { + delete fromNodesLookup[toElKey]; + } + if (!childrenOnly2) { + var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl); + if (beforeUpdateResult === false) { + return; + } else if (beforeUpdateResult instanceof HTMLElement) { + fromEl = beforeUpdateResult; + indexTree(fromEl); + } + morphAttrs2(fromEl, toEl); + onElUpdated(fromEl); + if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { + return; + } + } + if (fromEl.nodeName !== "TEXTAREA") { + morphChildren(fromEl, toEl); + } else { + specialElHandlers.TEXTAREA(fromEl, toEl); + } + } + function morphChildren(fromEl, toEl) { + var skipFrom = skipFromChildren(fromEl, toEl); + var curToNodeChild = toEl.firstChild; + var curFromNodeChild = fromEl.firstChild; + var curToNodeKey; + var curFromNodeKey; + var fromNextSibling; + var toNextSibling; + var matchingFromEl; + outer: + while (curToNodeChild) { + toNextSibling = curToNodeChild.nextSibling; + curToNodeKey = getNodeKey(curToNodeChild); + while (!skipFrom && curFromNodeChild) { + fromNextSibling = curFromNodeChild.nextSibling; + if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + curFromNodeKey = getNodeKey(curFromNodeChild); + var curFromNodeType = curFromNodeChild.nodeType; + var isCompatible = void 0; + if (curFromNodeType === curToNodeChild.nodeType) { + if (curFromNodeType === ELEMENT_NODE) { + if (curToNodeKey) { + if (curToNodeKey !== curFromNodeKey) { + if (matchingFromEl = fromNodesLookup[curToNodeKey]) { + if (fromNextSibling === matchingFromEl) { + isCompatible = false; + } else { + fromEl.insertBefore(matchingFromEl, curFromNodeChild); + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = matchingFromEl; + curFromNodeKey = getNodeKey(curFromNodeChild); + } + } else { + isCompatible = false; + } + } + } else if (curFromNodeKey) { + isCompatible = false; + } + isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); + if (isCompatible) { + morphEl(curFromNodeChild, curToNodeChild); + } + } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { + isCompatible = true; + if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { + curFromNodeChild.nodeValue = curToNodeChild.nodeValue; + } + } + } + if (isCompatible) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = fromNextSibling; + } + if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { + if (!skipFrom) { + addChild(fromEl, matchingFromEl); + } + morphEl(matchingFromEl, curToNodeChild); + } else { + var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); + if (onBeforeNodeAddedResult !== false) { + if (onBeforeNodeAddedResult) { + curToNodeChild = onBeforeNodeAddedResult; + } + if (curToNodeChild.actualize) { + curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); + } + addChild(fromEl, curToNodeChild); + handleNodeAdded(curToNodeChild); + } + } + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + } + cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); + var specialElHandler = specialElHandlers[fromEl.nodeName]; + if (specialElHandler) { + specialElHandler(fromEl, toEl); + } + } + var morphedNode = fromNode; + var morphedNodeType = morphedNode.nodeType; + var toNodeType = toNode.nodeType; + if (!childrenOnly) { + if (morphedNodeType === ELEMENT_NODE) { + if (toNodeType === ELEMENT_NODE) { + if (!compareNodeNames(fromNode, toNode)) { + onNodeDiscarded(fromNode); + morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); + } + } else { + morphedNode = toNode; + } + } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { + if (toNodeType === morphedNodeType) { + if (morphedNode.nodeValue !== toNode.nodeValue) { + morphedNode.nodeValue = toNode.nodeValue; + } + return morphedNode; + } else { + morphedNode = toNode; + } + } + } + if (morphedNode === toNode) { + onNodeDiscarded(fromNode); + } else { + if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { + return; + } + morphEl(morphedNode, toNode, childrenOnly); + if (keyedRemovalList) { + for (var i = 0, len = keyedRemovalList.length; i < len; i++) { + var elToRemove = fromNodesLookup[keyedRemovalList[i]]; + if (elToRemove) { + removeNode(elToRemove, elToRemove.parentNode, false); + } + } + } + } + if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { + if (morphedNode.actualize) { + morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); + } + fromNode.parentNode.replaceChild(morphedNode, fromNode); + } + return morphedNode; + }; + } + var morphdom = morphdomFactory(morphAttrs); + var morphdom_esm_default = morphdom; + var DOMPatch = class { + constructor(view, container, id, html, streams, targetCID, opts = {}) { + this.view = view; + this.liveSocket = view.liveSocket; + this.container = container; + this.id = id; + this.rootID = view.root.id; + this.html = html; + this.streams = streams; + this.streamInserts = {}; + this.streamComponentRestore = {}; + this.targetCID = targetCID; + this.cidPatch = isCid(this.targetCID); + this.pendingRemoves = []; + this.phxRemove = this.liveSocket.binding("remove"); + this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container; + this.callbacks = { + beforeadded: [], + beforeupdated: [], + beforephxChildAdded: [], + afteradded: [], + afterupdated: [], + afterdiscarded: [], + afterphxChildAdded: [], + aftertransitionsDiscarded: [] + }; + this.withChildren = opts.withChildren || opts.undoRef !== void 0 || false; + this.undoRef = opts.undoRef; + } + before(kind, callback) { + this.callbacks[`before${kind}`].push(callback); + } + after(kind, callback) { + this.callbacks[`after${kind}`].push(callback); + } + trackBefore(kind, ...args) { + this.callbacks[`before${kind}`].forEach((callback) => callback(...args)); + } + trackAfter(kind, ...args) { + this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); + } + markPrunableContentForRemoval() { + const phxUpdate = this.liveSocket.binding(PHX_UPDATE); + dom_default.all( + this.container, + `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, + (el) => { + el.setAttribute(PHX_PRUNE, ""); + } + ); + } + perform(isJoinPatch) { + const { view, liveSocket: liveSocket2, html, container } = this; + let targetContainer = this.targetContainer; + if (this.isCIDPatch() && !this.targetContainer) { + return; + } + if (this.isCIDPatch()) { + const closestLock = targetContainer.closest(`[${PHX_REF_LOCK}]`); + if (closestLock && !closestLock.isSameNode(targetContainer)) { + const clonedTree = dom_default.private(closestLock, PHX_REF_LOCK); + if (clonedTree) { + targetContainer = clonedTree.querySelector( + `[data-phx-component="${this.targetCID}"]` + ); + if (!targetContainer) + return; + } + } + } + const focused = liveSocket2.getActiveElement(); + const { selectionStart, selectionEnd } = focused && dom_default.hasSelectionRange(focused) ? focused : {}; + const phxUpdate = liveSocket2.binding(PHX_UPDATE); + const phxViewportTop = liveSocket2.binding(PHX_VIEWPORT_TOP); + const phxViewportBottom = liveSocket2.binding(PHX_VIEWPORT_BOTTOM); + const phxTriggerExternal = liveSocket2.binding(PHX_TRIGGER_ACTION); + const added = []; + const updates = []; + const appendPrependUpdates = []; + let portalCallbacks = []; + let externalFormTriggered = null; + const morph = (targetContainer2, source, withChildren = this.withChildren) => { + const morphCallbacks = { + // normally, we are running with childrenOnly, as the patch HTML for a LV + // does not include the LV attrs (data-phx-session, etc.) + // when we are patching a live component, we do want to patch the root element as well; + // another case is the recursive patch of a stream item that was kept on reset (-> onBeforeNodeAdded) + childrenOnly: targetContainer2.getAttribute(PHX_COMPONENT) === null && !withChildren, + getNodeKey: (node) => { + if (dom_default.isPhxDestroyed(node)) { + return null; + } + if (isJoinPatch) { + return node.id; + } + if (dom_default.private(node, "clientsideIdAttribute")) { + return node.getAttribute && node.getAttribute(PHX_MAGIC_ID); + } + return node.id || node.getAttribute && node.getAttribute(PHX_MAGIC_ID); + }, + // skip indexing from children when container is stream + skipFromChildren: (from) => { + return from.getAttribute(phxUpdate) === PHX_STREAM; + }, + // tell morphdom how to add a child + addChild: (parent, child) => { + const { ref, streamAt } = this.getStreamInsert(child); + if (ref === void 0) { + return parent.appendChild(child); + } + this.setStreamRef(child, ref); + if (streamAt === 0) { + parent.insertAdjacentElement("afterbegin", child); + } else if (streamAt === -1) { + const lastChild = parent.lastElementChild; + if (lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)) { + const nonStreamChild = Array.from(parent.children).find( + (c) => !c.hasAttribute(PHX_STREAM_REF) + ); + parent.insertBefore(child, nonStreamChild); + } else { + parent.appendChild(child); + } + } else if (streamAt > 0) { + const sibling = Array.from(parent.children)[streamAt]; + parent.insertBefore(child, sibling); + } + }, + onBeforeNodeAdded: (el) => { + if (this.getStreamInsert(el)?.updateOnly && !this.streamComponentRestore[el.id]) { + return false; + } + dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.trackBefore("added", el); + let morphedEl = el; + if (this.streamComponentRestore[el.id]) { + morphedEl = this.streamComponentRestore[el.id]; + delete this.streamComponentRestore[el.id]; + morph(morphedEl, el, true); + } + return morphedEl; + }, + onNodeAdded: (el) => { + if (el.getAttribute) { + this.maybeReOrderStream(el, true); + } + if (dom_default.isPortalTemplate(el)) { + portalCallbacks.push(() => this.teleport(el, morph)); + } + if (el instanceof HTMLImageElement && el.srcset) { + el.srcset = el.srcset; + } else if (el instanceof HTMLVideoElement && el.autoplay) { + el.play(); + } + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + if (dom_default.isPhxChild(el) && view.ownsElement(el) || dom_default.isPhxSticky(el) && view.ownsElement(el.parentNode)) { + this.trackAfter("phxChildAdded", el); + } + if (el.nodeName === "SCRIPT" && el.hasAttribute(PHX_RUNTIME_HOOK)) { + this.handleRuntimeHook(el, source); + } + added.push(el); + }, + onNodeDiscarded: (el) => this.onNodeDiscarded(el), + onBeforeNodeDiscarded: (el) => { + if (el.getAttribute && el.getAttribute(PHX_PRUNE) !== null) { + return true; + } + if (el.parentElement !== null && el.id && dom_default.isPhxUpdate(el.parentElement, phxUpdate, [ + PHX_STREAM, + "append", + "prepend" + ])) { + return false; + } + if (el.getAttribute && el.getAttribute(PHX_TELEPORTED_REF)) { + return false; + } + if (this.maybePendingRemove(el)) { + return false; + } + if (this.skipCIDSibling(el)) { + return false; + } + if (dom_default.isPortalTemplate(el)) { + const teleportedEl = document.getElementById( + el.content.firstElementChild.id + ); + if (teleportedEl) { + teleportedEl.remove(); + morphCallbacks.onNodeDiscarded(teleportedEl); + this.view.dropPortalElementId(teleportedEl.id); + } + } + return true; + }, + onElUpdated: (el) => { + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + updates.push(el); + this.maybeReOrderStream(el, false); + }, + onBeforeElUpdated: (fromEl, toEl) => { + if (fromEl.id && fromEl.isSameNode(targetContainer2) && fromEl.id !== toEl.id) { + morphCallbacks.onNodeDiscarded(fromEl); + fromEl.replaceWith(toEl); + return morphCallbacks.onNodeAdded(toEl); + } + dom_default.syncPendingAttrs(fromEl, toEl); + dom_default.maintainPrivateHooks( + fromEl, + toEl, + phxViewportTop, + phxViewportBottom + ); + dom_default.cleanChildNodes(toEl, phxUpdate); + const isFocusedFormEl = focused && fromEl.isSameNode(focused) && dom_default.isFormInput(fromEl); + const focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl); + if (this.skipCIDSibling(toEl)) { + this.maybeCloneLockedElement(fromEl, isFocusedFormEl); + this.copyNestedPrivateLock(fromEl, toEl); + this.maybeReOrderStream(fromEl); + return false; + } + if (dom_default.isPhxSticky(fromEl)) { + [PHX_SESSION, PHX_STATIC, PHX_ROOT_ID].map((attr) => [ + attr, + fromEl.getAttribute(attr), + toEl.getAttribute(attr) + ]).forEach(([attr, fromVal, toVal]) => { + if (toVal && fromVal !== toVal) { + fromEl.setAttribute(attr, toVal); + } + }); + return false; + } + if (dom_default.isIgnored(fromEl, phxUpdate) || fromEl.form && fromEl.form.isSameNode(externalFormTriggered)) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeAttrs(fromEl, toEl, { + isIgnored: dom_default.isIgnored(fromEl, phxUpdate) + }); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } + if (fromEl.type === "number" && fromEl.validity && fromEl.validity.badInput) { + return false; + } + fromEl = this.maybeCloneLockedElement(fromEl, isFocusedFormEl); + if (dom_default.isPhxChild(toEl)) { + const prevSession = fromEl.getAttribute(PHX_SESSION); + dom_default.mergeAttrs(fromEl, toEl, { exclude: [PHX_STATIC] }); + if (prevSession !== "") { + fromEl.setAttribute(PHX_SESSION, prevSession); + } + fromEl.setAttribute(PHX_ROOT_ID, this.rootID); + dom_default.applyStickyOperations(fromEl); + return false; + } + this.copyNestedPrivateLock(fromEl, toEl); + dom_default.copyPrivates(toEl, fromEl); + if (dom_default.isPortalTemplate(toEl)) { + portalCallbacks.push(() => this.teleport(toEl, morph)); + fromEl.content.replaceChildren(toEl.content.cloneNode(true)); + return false; + } + if (isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeFocusedInput(fromEl, toEl); + dom_default.syncAttrsToProps(fromEl); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } else { + if (focusedSelectChanged) { + fromEl.blur(); + } + if (dom_default.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])) { + appendPrependUpdates.push( + new DOMPostMorphRestorer( + fromEl, + toEl, + toEl.getAttribute(phxUpdate) + ) + ); + } + dom_default.syncAttrsToProps(toEl); + dom_default.applyStickyOperations(toEl); + this.trackBefore("updated", fromEl, toEl); + return fromEl; + } + } + }; + morphdom_esm_default(targetContainer2, source, morphCallbacks); + }; + this.trackBefore("added", container); + this.trackBefore("updated", container, container); + liveSocket2.time("morphdom", () => { + this.streams.forEach(([ref, inserts, deleteIds, reset]) => { + inserts.forEach(([key, streamAt, limit, updateOnly]) => { + this.streamInserts[key] = { ref, streamAt, limit, reset, updateOnly }; + }); + if (reset !== void 0) { + dom_default.all(document, `[${PHX_STREAM_REF}="${ref}"]`, (child) => { + this.removeStreamChildElement(child); + }); + } + deleteIds.forEach((id) => { + const child = document.getElementById(id); + if (child) { + this.removeStreamChildElement(child); + } + }); + }); + if (isJoinPatch) { + dom_default.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`).filter((el) => this.view.ownsElement(el)).forEach((el) => { + Array.from(el.children).forEach((child) => { + this.removeStreamChildElement(child, true); + }); + }); + } + morph(targetContainer, html); + let teleportCount = 0; + while (portalCallbacks.length > 0 && teleportCount < 5) { + const copy = portalCallbacks.slice(); + portalCallbacks = []; + copy.forEach((callback) => callback()); + teleportCount++; + } + this.view.portalElementIds.forEach((id) => { + const el = document.getElementById(id); + if (el) { + const source = document.getElementById( + el.getAttribute(PHX_TELEPORTED_SRC) + ); + if (!source) { + el.remove(); + this.onNodeDiscarded(el); + this.view.dropPortalElementId(id); + } + } + }); + }); + if (liveSocket2.isDebugEnabled()) { + detectDuplicateIds(); + detectInvalidStreamInserts(this.streamInserts); + Array.from(document.querySelectorAll("input[name=id]")).forEach( + (node) => { + if (node instanceof HTMLInputElement && node.form) { + console.error( + 'Detected an input with name="id" inside a form! This will cause problems when patching the DOM.\n', + node + ); + } + } + ); + } + if (appendPrependUpdates.length > 0) { + liveSocket2.time("post-morph append/prepend restoration", () => { + appendPrependUpdates.forEach((update) => update.perform()); + }); + } + liveSocket2.silenceEvents( + () => dom_default.restoreFocus(focused, selectionStart, selectionEnd) + ); + dom_default.dispatchEvent(document, "phx:update"); + added.forEach((el) => this.trackAfter("added", el)); + updates.forEach((el) => this.trackAfter("updated", el)); + this.transitionPendingRemoves(); + if (externalFormTriggered) { + liveSocket2.unload(); + const submitter = dom_default.private(externalFormTriggered, "submitter"); + if (submitter && submitter.name && targetContainer.contains(submitter)) { + const input = document.createElement("input"); + input.type = "hidden"; + const formId = submitter.getAttribute("form"); + if (formId) { + input.setAttribute("form", formId); + } + input.name = submitter.name; + input.value = submitter.value; + submitter.parentElement.insertBefore(input, submitter); + } + Object.getPrototypeOf(externalFormTriggered).submit.call( + externalFormTriggered + ); + } + return true; + } + onNodeDiscarded(el) { + if (dom_default.isPhxChild(el) || dom_default.isPhxSticky(el)) { + this.liveSocket.destroyViewByEl(el); + } + this.trackAfter("discarded", el); + } + maybePendingRemove(node) { + if (node.getAttribute && node.getAttribute(this.phxRemove) !== null) { + this.pendingRemoves.push(node); + return true; + } else { + return false; + } + } + removeStreamChildElement(child, force = false) { + if (!force && !this.view.ownsElement(child)) { + return; + } + if (this.streamInserts[child.id]) { + this.streamComponentRestore[child.id] = child; + child.remove(); + } else { + if (!this.maybePendingRemove(child)) { + child.remove(); + this.onNodeDiscarded(child); + } + } + } + getStreamInsert(el) { + const insert = el.id ? this.streamInserts[el.id] : {}; + return insert || {}; + } + setStreamRef(el, ref) { + dom_default.putSticky( + el, + PHX_STREAM_REF, + (el2) => el2.setAttribute(PHX_STREAM_REF, ref) + ); + } + maybeReOrderStream(el, isNew) { + const { ref, streamAt, reset } = this.getStreamInsert(el); + if (streamAt === void 0) { + return; + } + this.setStreamRef(el, ref); + if (!reset && !isNew) { + return; + } + if (!el.parentElement) { + return; + } + if (streamAt === 0) { + this.moveOrInsertBefore( + el.parentElement, + el, + el.parentElement.firstElementChild + ); + } else if (streamAt > 0) { + const children = Array.from(el.parentElement.children); + const oldIndex = children.indexOf(el); + if (streamAt >= children.length - 1) { + this.moveOrInsertBefore(el.parentElement, el, null); + } else { + const sibling = children[streamAt]; + if (oldIndex > streamAt) { + this.moveOrInsertBefore(el.parentElement, el, sibling); + } else { + this.moveOrInsertBefore( + el.parentElement, + el, + sibling.nextElementSibling + ); + } + } + } + this.maybeLimitStream(el); + } + // Reorder a child within its parent. When supported, use the atomic + // moveBefore (https://developer.mozilla.org/en-US/docs/Web/API/Node/moveBefore) + // so connected custom elements (and other state-bearing nodes like iframes) + // are not disconnected and reconnected by the move. Falls back to + // insertBefore otherwise. Passing `ref === null` moves to the end. + // See also https://github.com/phoenixframework/phoenix_live_view/issues/4212. + moveOrInsertBefore(parent, child, ref) { + if (typeof parent.moveBefore === "function") { + try { + parent.moveBefore(child, ref); + return; + } catch { + } + } + parent.insertBefore(child, ref); + } + maybeLimitStream(el) { + const { limit } = this.getStreamInsert(el); + const children = limit !== null && Array.from(el.parentElement.children); + if (limit && limit < 0 && children.length > limit * -1) { + children.slice(0, children.length + limit).forEach((child) => this.removeStreamChildElement(child)); + } else if (limit && limit >= 0 && children.length > limit) { + children.slice(limit).forEach((child) => this.removeStreamChildElement(child)); + } + } + transitionPendingRemoves() { + const { pendingRemoves, liveSocket: liveSocket2 } = this; + if (pendingRemoves.length > 0) { + liveSocket2.transitionRemoves(pendingRemoves, () => { + pendingRemoves.forEach((el) => { + const child = dom_default.firstPhxChild(el); + if (child) { + liveSocket2.destroyViewByEl(child); + } + el.remove(); + }); + this.trackAfter("transitionsDiscarded", pendingRemoves); + }); + } + } + isChangedSelect(fromEl, toEl) { + if (!(fromEl instanceof HTMLSelectElement) || fromEl.multiple) { + return false; + } + if (fromEl.options.length !== toEl.options.length) { + return true; + } + toEl.value = fromEl.value; + return !fromEl.isEqualNode(toEl); + } + isCIDPatch() { + return this.cidPatch; + } + skipCIDSibling(el) { + return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP); + } + maybeCloneLockedElement(fromEl, isFocusedFormEl) { + if (!fromEl.hasAttribute(PHX_REF_SRC)) + return fromEl; + const ref = new ElementRef(fromEl); + if (ref.lockRef === null || this.undoRef !== void 0 && ref.isLockUndoneBy(this.undoRef)) { + return fromEl; + } + dom_default.applyStickyOperations(fromEl); + const clone2 = fromEl.hasAttribute(PHX_REF_LOCK) ? dom_default.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null; + if (!clone2) + return fromEl; + dom_default.putPrivate(fromEl, PHX_REF_LOCK, clone2); + return isFocusedFormEl ? fromEl : clone2; + } + copyNestedPrivateLock(fromEl, toEl) { + if (this.undoRef === void 0 || !dom_default.private(toEl, PHX_REF_LOCK)) + return; + dom_default.putPrivate(fromEl, PHX_REF_LOCK, dom_default.private(toEl, PHX_REF_LOCK)); + } + targetCIDContainer(html) { + if (!this.isCIDPatch()) { + return; + } + const [first, ...rest] = dom_default.findComponentNodeList( + this.view.id, + this.targetCID + ); + if (rest.length === 0 && dom_default.childNodeLength(html) === 1) { + return first; + } else { + return first && first.parentNode; + } + } + indexOf(parent, child) { + return Array.from(parent.children).indexOf(child); + } + teleport(el, morph) { + const targetSelector = el.getAttribute(PHX_PORTAL); + const portalContainer = document.querySelector(targetSelector); + if (!portalContainer) { + throw new Error( + "portal target with selector " + targetSelector + " not found" + ); + } + const toTeleport = el.content.firstElementChild; + if (this.skipCIDSibling(toTeleport)) { + return; + } + if (!toTeleport?.id) { + throw new Error( + "phx-portal template must have a single root element with ID!" + ); + } + const existing = document.getElementById(toTeleport.id); + let portalTarget; + if (existing) { + if (!portalContainer.contains(existing)) { + portalContainer.appendChild(existing); + } + portalTarget = existing; + } else { + portalTarget = document.createElement(toTeleport.tagName); + portalContainer.appendChild(portalTarget); + } + toTeleport.setAttribute(PHX_TELEPORTED_REF, this.view.id); + toTeleport.setAttribute(PHX_TELEPORTED_SRC, el.id); + morph(portalTarget, toTeleport, true); + toTeleport.removeAttribute(PHX_TELEPORTED_REF); + toTeleport.removeAttribute(PHX_TELEPORTED_SRC); + this.view.pushPortalElementId(toTeleport.id); + } + handleRuntimeHook(el, source) { + const name = el.getAttribute(PHX_RUNTIME_HOOK); + let nonce = el.hasAttribute("nonce") ? el.getAttribute("nonce") : null; + if (el.hasAttribute("nonce")) { + const template = document.createElement("template"); + template.innerHTML = source; + nonce = template.content.querySelector(`script[${PHX_RUNTIME_HOOK}="${CSS.escape(name)}"]`).getAttribute("nonce"); + } + const script = document.createElement("script"); + script.textContent = el.textContent; + dom_default.mergeAttrs(script, el, { isIgnored: false }); + if (nonce) { + script.nonce = nonce; + } + el.replaceWith(script); + el = script; + } + }; + var VOID_TAGS = /* @__PURE__ */ new Set([ + "area", + "base", + "br", + "col", + "command", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "track", + "wbr" + ]); + var quoteChars = /* @__PURE__ */ new Set(["'", '"']); + var modifyRoot = (html, attrs, clearInnerHTML) => { + let i = 0; + let insideComment = false; + let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML; + const lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/); + if (lookahead === null) { + throw new Error(`malformed html ${html}`); + } + i = lookahead[0].length; + beforeTag = lookahead[1]; + tag = lookahead[2]; + tagNameEndsAt = i; + for (i; i < html.length; i++) { + if (html.charAt(i) === ">") { + break; + } + if (html.charAt(i) === "=") { + const isId = html.slice(i - 3, i) === " id"; + i++; + const char = html.charAt(i); + if (quoteChars.has(char)) { + const attrStartsAt = i; + i++; + for (i; i < html.length; i++) { + if (html.charAt(i) === char) { + break; + } + } + if (isId) { + id = html.slice(attrStartsAt + 1, i); + break; + } + } + } + } + let closeAt = html.length - 1; + insideComment = false; + while (closeAt >= beforeTag.length + tag.length) { + const char = html.charAt(closeAt); + if (insideComment) { + if (char === "-" && html.slice(closeAt - 3, closeAt) === "" && html.slice(closeAt - 2, closeAt) === "--") { + insideComment = true; + closeAt -= 3; + } else if (char === ">") { + break; + } else { + closeAt -= 1; + } + } + afterTag = html.slice(closeAt + 1, html.length); + const attrsStr = Object.keys(attrs).map((attr) => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`).join(" "); + if (clearInnerHTML) { + const idAttrStr = id ? ` id="${id}"` : ""; + if (VOID_TAGS.has(tag)) { + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>`; + } else { + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>`; + } + } else { + const rest = html.slice(tagNameEndsAt, closeAt + 1); + newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`; + } + return [newHTML, beforeTag, afterTag]; + }; + var Rendered = class { + static extract(diff) { + const { [REPLY]: reply, [EVENTS]: events, [TITLE]: title } = diff; + delete diff[REPLY]; + delete diff[EVENTS]; + delete diff[TITLE]; + return { diff, title, reply: reply || null, events: events || [] }; + } + constructor(viewId, rendered) { + this.viewId = viewId; + this.rendered = {}; + this.magicId = 0; + this.mergeDiff(rendered); + } + parentViewId() { + return this.viewId; + } + toString(onlyCids) { + const { buffer: str, streams } = this.recursiveToString( + this.rendered, + this.rendered[COMPONENTS], + onlyCids, + true, + {} + ); + return { buffer: str, streams }; + } + recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs) { + onlyCids = onlyCids ? new Set(onlyCids) : null; + const output = { + buffer: "", + components, + onlyCids, + streams: /* @__PURE__ */ new Set() + }; + this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs); + return { buffer: output.buffer, streams: output.streams }; + } + componentCIDs(diff) { + return Object.keys(diff[COMPONENTS] || {}).map((i) => parseInt(i)); + } + isComponentOnlyDiff(diff) { + if (!diff[COMPONENTS]) { + return false; + } + return Object.keys(diff).length === 1; + } + getComponent(diff, cid) { + return diff[COMPONENTS][cid]; + } + resetRender(cid) { + if (this.rendered[COMPONENTS][cid]) { + this.rendered[COMPONENTS][cid].reset = true; + } + } + mergeDiff(diff) { + const newc = diff[COMPONENTS]; + const cache = {}; + delete diff[COMPONENTS]; + this.rendered = this.mutableMerge(this.rendered, diff); + this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {}; + if (newc) { + const oldc = this.rendered[COMPONENTS]; + for (const cid in newc) { + newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache); + } + for (const cid in newc) { + oldc[cid] = newc[cid]; + } + diff[COMPONENTS] = newc; + } + } + cachedFindComponent(cid, cdiff, oldc, newc, cache) { + if (cache[cid]) { + return cache[cid]; + } else { + let ndiff, stat, scid = cdiff[STATIC]; + if (isCid(scid)) { + let tdiff; + if (scid > 0) { + tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache); + } else { + tdiff = oldc[-scid]; + } + stat = tdiff[STATIC]; + ndiff = this.cloneMerge(tdiff, cdiff, true); + ndiff[STATIC] = stat; + } else { + ndiff = cdiff[STATIC] !== void 0 || oldc[cid] === void 0 ? cdiff : this.cloneMerge(oldc[cid], cdiff, false); + } + cache[cid] = ndiff; + return ndiff; + } + } + mutableMerge(target, source) { + if (source[STATIC] !== void 0) { + return source; + } else { + this.doMutableMerge(target, source); + return target; + } + } + doMutableMerge(target, source) { + if (source[KEYED]) { + this.mergeKeyed(target, source); + } else { + for (const key in source) { + const val = source[key]; + const targetVal = target[key]; + const isObjVal = isObject(val); + if (isObjVal && val[STATIC] === void 0 && isObject(targetVal)) { + this.doMutableMerge(targetVal, val); + } else { + target[key] = val; + } + } + } + if (target[ROOT]) { + target.newRender = true; + } + } + clone(diff) { + if ("structuredClone" in window) { + return structuredClone(diff); + } else { + return JSON.parse(JSON.stringify(diff)); + } + } + // keyed comprehensions + mergeKeyed(target, source) { + const clonedTarget = this.clone(target); + Object.entries(source[KEYED]).forEach(([i, entry]) => { + if (i === KEYED_COUNT) { + return; + } + if (Array.isArray(entry)) { + const [old_idx, diff] = entry; + target[KEYED][i] = clonedTarget[KEYED][old_idx]; + this.doMutableMerge(target[KEYED][i], diff); + } else if (typeof entry === "number") { + const old_idx = entry; + target[KEYED][i] = clonedTarget[KEYED][old_idx]; + } else if (typeof entry === "object") { + if (!target[KEYED][i]) { + target[KEYED][i] = {}; + } + this.doMutableMerge(target[KEYED][i], entry); + } + }); + if (source[KEYED][KEYED_COUNT] < target[KEYED][KEYED_COUNT]) { + for (let i = source[KEYED][KEYED_COUNT]; i < target[KEYED][KEYED_COUNT]; i++) { + delete target[KEYED][i]; + } + } + target[KEYED][KEYED_COUNT] = source[KEYED][KEYED_COUNT]; + if (source[STREAM]) { + target[STREAM] = source[STREAM]; + } + if (source[TEMPLATES]) { + target[TEMPLATES] = source[TEMPLATES]; + } + } + // Merges cid trees together, copying statics from source tree. + // + // The `pruneMagicId` is passed to control pruning the magicId of the + // target. We must always prune the magicId when we are sharing statics + // from another component. If not pruning, we replicate the logic from + // mutableMerge, where we set newRender to true if there is a root + // (effectively forcing the new version to be rendered instead of skipped) + // + cloneMerge(target, source, pruneMagicId) { + let merged; + if (source[KEYED]) { + merged = this.clone(target); + this.mergeKeyed(merged, source); + } else { + merged = { ...target, ...source }; + for (const key in merged) { + const val = source[key]; + const targetVal = target[key]; + if (isObject(val) && val[STATIC] === void 0 && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, val, pruneMagicId); + } else if (val === void 0 && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, {}, pruneMagicId); + } + } + } + if (pruneMagicId) { + delete merged.magicId; + delete merged.newRender; + } else if (target[ROOT]) { + merged.newRender = true; + } + return merged; + } + componentToString(cid) { + const { buffer: str, streams } = this.recursiveCIDToString( + this.rendered[COMPONENTS], + cid, + null + ); + const [strippedHTML, _before, _after] = modifyRoot(str, {}); + return { buffer: strippedHTML, streams }; + } + pruneCIDs(cids) { + cids.forEach((cid) => delete this.rendered[COMPONENTS][cid]); + } + // private + get() { + return this.rendered; + } + isNewFingerprint(diff = {}) { + return !!diff[STATIC]; + } + templateStatic(part, templates) { + if (typeof part === "number") { + return templates[part]; + } else { + return part; + } + } + nextMagicID() { + this.magicId++; + return `m${this.magicId}-${this.parentViewId()}`; + } + // Converts rendered tree to output buffer. + // + // changeTracking controls if we can apply the PHX_SKIP optimization. + toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}) { + if (rendered[KEYED]) { + return this.comprehensionToBuffer( + rendered, + templates, + output, + changeTracking + ); + } + if (rendered[TEMPLATES]) { + templates = rendered[TEMPLATES]; + delete rendered[TEMPLATES]; + } + let { [STATIC]: statics } = rendered; + statics = this.templateStatic(statics, templates); + rendered[STATIC] = statics; + const isRoot = rendered[ROOT]; + const prevBuffer = output.buffer; + if (isRoot) { + output.buffer = ""; + } + if (changeTracking && isRoot && !rendered.magicId) { + rendered.newRender = true; + rendered.magicId = this.nextMagicID(); + } + output.buffer += statics[0]; + for (let i = 1; i < statics.length; i++) { + this.dynamicToBuffer(rendered[i - 1], templates, output, changeTracking); + output.buffer += statics[i]; + } + if (isRoot) { + let skip = false; + let attrs; + if (changeTracking || rendered.magicId) { + skip = changeTracking && !rendered.newRender; + attrs = { [PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs }; + } else { + attrs = rootAttrs; + } + if (skip) { + attrs[PHX_SKIP] = true; + } + const [newRoot, commentBefore, commentAfter] = modifyRoot( + output.buffer, + attrs, + skip + ); + rendered.newRender = false; + output.buffer = prevBuffer + commentBefore + newRoot + commentAfter; + } + } + comprehensionToBuffer(rendered, templates, output, changeTracking) { + const keyedTemplates = templates || rendered[TEMPLATES]; + const statics = this.templateStatic(rendered[STATIC], templates); + rendered[STATIC] = statics; + delete rendered[TEMPLATES]; + for (let i = 0; i < rendered[KEYED][KEYED_COUNT]; i++) { + output.buffer += statics[0]; + for (let j = 1; j < statics.length; j++) { + this.dynamicToBuffer( + rendered[KEYED][i][j - 1], + keyedTemplates, + output, + changeTracking + ); + output.buffer += statics[j]; + } + } + if (rendered[STREAM]) { + const stream = rendered[STREAM]; + const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]; + if (stream !== void 0 && (rendered[KEYED][KEYED_COUNT] > 0 || deleteIds.length > 0 || reset)) { + delete rendered[STREAM]; + rendered[KEYED] = { + [KEYED_COUNT]: 0 + }; + output.streams.add(stream); + } + } + } + dynamicToBuffer(rendered, templates, output, changeTracking) { + if (typeof rendered === "number") { + const { buffer: str, streams } = this.recursiveCIDToString( + output.components, + rendered, + output.onlyCids + ); + output.buffer += str; + output.streams = /* @__PURE__ */ new Set([...output.streams, ...streams]); + } else if (isObject(rendered)) { + this.toOutputBuffer(rendered, templates, output, changeTracking, {}); + } else { + output.buffer += rendered; + } + } + recursiveCIDToString(components, cid, onlyCids) { + const component = components[cid] || logError(`no component for CID ${cid}`, components); + const attrs = { [PHX_COMPONENT]: cid, [PHX_VIEW_REF]: this.viewId }; + const skip = onlyCids && !onlyCids.has(cid); + component.newRender = !skip; + component.magicId = `c${cid}-${this.parentViewId()}`; + const changeTracking = !component.reset; + const { buffer: html, streams } = this.recursiveToString( + component, + components, + onlyCids, + changeTracking, + attrs + ); + delete component.reset; + return { buffer: html, streams }; + } + }; + var focusStack = []; + var default_transition_time = 200; + var JS = { + // private + exec(e, eventType, phxEvent, view, sourceEl, defaults) { + const [defaultKind, defaultArgs] = defaults || [ + null, + { callback: defaults && defaults.callback } + ]; + const commands = phxEvent.charAt(0) === "[" ? JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]; + commands.forEach(([kind, args]) => { + if (kind === defaultKind) { + args = { ...defaultArgs, ...args }; + args.callback = args.callback || defaultArgs.callback; + } + this.filterToEls(view.liveSocket, sourceEl, args).forEach((el) => { + this[`exec_${kind}`](e, eventType, phxEvent, view, sourceEl, el, args); + }); + }); + }, + isVisible(el) { + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0); + }, + // returns true if any part of the element is inside the viewport + isInViewport(el) { + const rect = el.getBoundingClientRect(); + const windowHeight = window.innerHeight || document.documentElement.clientHeight; + const windowWidth = window.innerWidth || document.documentElement.clientWidth; + return rect.right > 0 && rect.bottom > 0 && rect.left < windowWidth && rect.top < windowHeight; + }, + // private + // commands + exec_exec(e, eventType, phxEvent, view, sourceEl, el, { attr, to }) { + const encodedJS = el.getAttribute(attr); + if (!encodedJS) { + throw new Error(`expected ${attr} to contain JS command on "${to}"`); + } + view.liveSocket.execJS(el, encodedJS, eventType); + }, + exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, { event, detail, bubbles, blocking }) { + detail = detail || {}; + detail.dispatcher = sourceEl; + if (blocking) { + const promise = new Promise((resolve, _reject) => { + detail.done = resolve; + }); + view.liveSocket.asyncTransition(promise); + } + dom_default.dispatchEvent(el, event, { detail, bubbles }); + }, + exec_push(e, eventType, phxEvent, view, sourceEl, el, args) { + const { + event, + data, + target, + page_loading, + loading, + value, + dispatcher, + callback + } = args; + const pushOpts = { + loading, + value, + target, + page_loading: !!page_loading, + originalEvent: e + }; + const targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl; + const phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc; + const handler = (targetView, targetCtx) => { + if (!targetView.isConnected()) { + return; + } + if (eventType === "change") { + let { newCid, _target } = args; + _target = _target || (dom_default.isFormInput(sourceEl) ? sourceEl.name : void 0); + if (_target) { + pushOpts._target = _target; + } + targetView.pushInput( + sourceEl, + targetCtx, + newCid, + event || phxEvent, + pushOpts, + callback + ); + } else if (eventType === "submit") { + const { submitter } = args; + targetView.submitForm( + sourceEl, + targetCtx, + event || phxEvent, + submitter, + pushOpts, + callback + ); + } else { + targetView.pushEvent( + eventType, + sourceEl, + targetCtx, + event || phxEvent, + data, + pushOpts, + callback + ); + } + }; + if (args.targetView && args.targetCtx) { + handler(args.targetView, args.targetCtx); + } else { + view.withinTargets(phxTarget, handler); + } + }, + exec_navigate(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.historyRedirect( + e, + href, + replace ? "replace" : "push", + null, + sourceEl + ); + }, + exec_patch(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.pushHistoryPatch( + e, + href, + replace ? "replace" : "push", + sourceEl + ); + }, + exec_focus(e, eventType, phxEvent, view, sourceEl, el) { + aria_default.attemptFocus(el); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => aria_default.attemptFocus(el)); + }); + }, + exec_focus_first(e, eventType, phxEvent, view, sourceEl, el) { + aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el); + window.requestAnimationFrame(() => { + window.requestAnimationFrame( + () => aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el) + ); + }); + }, + exec_push_focus(e, eventType, phxEvent, view, sourceEl, el) { + focusStack.push(el || sourceEl); + }, + exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el) { + const el = focusStack.pop(); + if (el) { + el.focus(); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => el.focus()); + }); + } + }, + exec_add_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { + this.addOrRemoveClasses(el, names, [], transition, time, view, blocking); + }, + exec_remove_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { + this.addOrRemoveClasses(el, [], names, transition, time, view, blocking); + }, + exec_toggle_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { + this.toggleClasses(el, names, transition, time, view, blocking); + }, + exec_toggle_attr(e, eventType, phxEvent, view, sourceEl, el, { attr: [attr, val1, val2] }) { + this.toggleAttr(el, attr, val1, val2); + }, + exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, { attrs }) { + this.ignoreAttrs(el, attrs); + }, + exec_transition(e, eventType, phxEvent, view, sourceEl, el, { time, transition, blocking }) { + this.addOrRemoveClasses(el, [], [], transition, time, view, blocking); + }, + exec_toggle(e, eventType, phxEvent, view, sourceEl, el, { display, ins, outs, time, blocking }) { + this.toggle(eventType, view, el, display, ins, outs, time, blocking); + }, + exec_show(e, eventType, phxEvent, view, sourceEl, el, { display, transition, time, blocking }) { + this.show(eventType, view, el, display, transition, time, blocking); + }, + exec_hide(e, eventType, phxEvent, view, sourceEl, el, { display, transition, time, blocking }) { + this.hide(eventType, view, el, display, transition, time, blocking); + }, + exec_set_attr(e, eventType, phxEvent, view, sourceEl, el, { attr: [attr, val] }) { + this.setOrRemoveAttrs(el, [[attr, val]], []); + }, + exec_remove_attr(e, eventType, phxEvent, view, sourceEl, el, { attr }) { + this.setOrRemoveAttrs(el, [], [attr]); + }, + ignoreAttrs(el, attrs) { + dom_default.putPrivate(el, "JS:ignore_attrs", { + apply: (fromEl, toEl) => { + let fromAttributes = Array.from(fromEl.attributes); + let fromAttributeNames = fromAttributes.map((attr) => attr.name); + Array.from(toEl.attributes).filter((attr) => { + return !fromAttributeNames.includes(attr.name); + }).forEach((attr) => { + if (dom_default.attributeIgnored(attr, attrs)) { + toEl.removeAttribute(attr.name); + } + }); + fromAttributes.forEach((attr) => { + if (dom_default.attributeIgnored(attr, attrs)) { + toEl.setAttribute(attr.name, attr.value); + } + }); + } + }); + }, + onBeforeElUpdated(fromEl, toEl) { + const ignoreAttrs = dom_default.private(fromEl, "JS:ignore_attrs"); + if (ignoreAttrs) { + ignoreAttrs.apply(fromEl, toEl); + } + }, + // utils for commands + show(eventType, view, el, display, transition, time, blocking) { + if (!this.isVisible(el)) { + this.toggle( + eventType, + view, + el, + display, + transition, + null, + time, + blocking + ); + } + }, + hide(eventType, view, el, display, transition, time, blocking) { + if (this.isVisible(el)) { + this.toggle( + eventType, + view, + el, + display, + null, + transition, + time, + blocking + ); + } + }, + toggle(eventType, view, el, display, ins, outs, time, blocking) { + time = time || default_transition_time; + const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []]; + const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []]; + if (inClasses.length > 0 || outClasses.length > 0) { + if (this.isVisible(el)) { + const onStart = () => { + this.addOrRemoveClasses( + el, + outStartClasses, + inClasses.concat(inStartClasses).concat(inEndClasses) + ); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, outClasses, []); + window.requestAnimationFrame( + () => this.addOrRemoveClasses(el, outEndClasses, outStartClasses) + ); + }); + }; + const onEnd = () => { + this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)); + dom_default.putSticky( + el, + "toggle", + (currentEl) => currentEl.style.display = "none" + ); + el.dispatchEvent(new Event("phx:hide-end")); + }; + el.dispatchEvent(new Event("phx:hide-start")); + if (blocking === false) { + onStart(); + setTimeout(onEnd, time); + } else { + view.transition(time, onStart, onEnd); + } + } else { + if (eventType === "remove") { + return; + } + const onStart = () => { + this.addOrRemoveClasses( + el, + inStartClasses, + outClasses.concat(outStartClasses).concat(outEndClasses) + ); + const stickyDisplay = display || this.defaultDisplay(el); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, inClasses, []); + window.requestAnimationFrame(() => { + dom_default.putSticky( + el, + "toggle", + (currentEl) => currentEl.style.display = stickyDisplay + ); + this.addOrRemoveClasses(el, inEndClasses, inStartClasses); + }); + }); + }; + const onEnd = () => { + this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)); + el.dispatchEvent(new Event("phx:show-end")); + }; + el.dispatchEvent(new Event("phx:show-start")); + if (blocking === false) { + onStart(); + setTimeout(onEnd, time); + } else { + view.transition(time, onStart, onEnd); + } + } + } else { + if (this.isVisible(el)) { + window.requestAnimationFrame(() => { + el.dispatchEvent(new Event("phx:hide-start")); + dom_default.putSticky( + el, + "toggle", + (currentEl) => currentEl.style.display = "none" + ); + el.dispatchEvent(new Event("phx:hide-end")); + }); + } else { + window.requestAnimationFrame(() => { + el.dispatchEvent(new Event("phx:show-start")); + const stickyDisplay = display || this.defaultDisplay(el); + dom_default.putSticky( + el, + "toggle", + (currentEl) => currentEl.style.display = stickyDisplay + ); + el.dispatchEvent(new Event("phx:show-end")); + }); + } + } + }, + toggleClasses(el, classes, transition, time, view, blocking) { + window.requestAnimationFrame(() => { + const [prevAdds, prevRemoves] = dom_default.getSticky(el, "classes", [[], []]); + const newAdds = classes.filter( + (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name) + ); + const newRemoves = classes.filter( + (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name) + ); + this.addOrRemoveClasses( + el, + newAdds, + newRemoves, + transition, + time, + view, + blocking + ); + }); + }, + toggleAttr(el, attr, val1, val2) { + if (el.hasAttribute(attr)) { + if (val2 !== void 0) { + if (el.getAttribute(attr) === val1) { + this.setOrRemoveAttrs(el, [[attr, val2]], []); + } else { + this.setOrRemoveAttrs(el, [[attr, val1]], []); + } + } else { + this.setOrRemoveAttrs(el, [], [attr]); + } + } else { + this.setOrRemoveAttrs(el, [[attr, val1]], []); + } + }, + addOrRemoveClasses(el, adds, removes, transition, time, view, blocking) { + time = time || default_transition_time; + const [transitionRun, transitionStart, transitionEnd] = transition || [ + [], + [], + [] + ]; + if (transitionRun.length > 0) { + const onStart = () => { + this.addOrRemoveClasses( + el, + transitionStart, + [].concat(transitionRun).concat(transitionEnd) + ); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, transitionRun, []); + window.requestAnimationFrame( + () => this.addOrRemoveClasses(el, transitionEnd, transitionStart) + ); + }); + }; + const onDone = () => this.addOrRemoveClasses( + el, + adds.concat(transitionEnd), + removes.concat(transitionRun).concat(transitionStart) + ); + if (blocking === false) { + onStart(); + setTimeout(onDone, time); + } else { + view.transition(time, onStart, onDone); + } + return; + } + window.requestAnimationFrame(() => { + const [prevAdds, prevRemoves] = dom_default.getSticky(el, "classes", [[], []]); + const keepAdds = adds.filter( + (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name) + ); + const keepRemoves = removes.filter( + (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name) + ); + const newAdds = prevAdds.filter((name) => removes.indexOf(name) < 0).concat(keepAdds); + const newRemoves = prevRemoves.filter((name) => adds.indexOf(name) < 0).concat(keepRemoves); + dom_default.putSticky(el, "classes", (currentEl) => { + currentEl.classList.remove(...newRemoves); + currentEl.classList.add(...newAdds); + return [newAdds, newRemoves]; + }); + }); + }, + setOrRemoveAttrs(el, sets, removes) { + const [prevSets, prevRemoves] = dom_default.getSticky(el, "attrs", [[], []]); + const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); + const newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets); + const newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes); + if (sets.some(([attr, _val]) => attr === "id")) { + dom_default.putPrivate(el, "clientsideIdAttribute", true); + } + dom_default.putSticky(el, "attrs", (currentEl) => { + newRemoves.forEach((attr) => currentEl.removeAttribute(attr)); + newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)); + return [newSets, newRemoves]; + }); + }, + hasAllClasses(el, classes) { + return classes.every((name) => el.classList.contains(name)); + }, + isToggledOut(el, outClasses) { + return !this.isVisible(el) || this.hasAllClasses(el, outClasses); + }, + filterToEls(liveSocket2, sourceEl, { to }) { + const defaultQuery = () => { + if (typeof to === "string") { + return document.querySelectorAll(to); + } else if (to.closest) { + const toEl = sourceEl.closest(to.closest); + return toEl ? [toEl] : []; + } else if (to.inner) { + return sourceEl.querySelectorAll(to.inner); + } + }; + return to ? liveSocket2.jsQuerySelectorAll(sourceEl, to, defaultQuery) : [sourceEl]; + }, + defaultDisplay(el) { + return { tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block"; + }, + transitionClasses(val) { + if (!val) { + return null; + } + let [trans, tStart, tEnd] = Array.isArray(val) ? val : [val.split(" "), [], []]; + trans = Array.isArray(trans) ? trans : trans.split(" "); + tStart = Array.isArray(tStart) ? tStart : tStart.split(" "); + tEnd = Array.isArray(tEnd) ? tEnd : tEnd.split(" "); + return [trans, tStart, tEnd]; + } + }; + var js_default = JS; + var js_commands_default = (liveSocket2, eventType) => { + return { + exec(el, encodedJS) { + liveSocket2.execJS(el, encodedJS, eventType); + }, + show(el, opts = {}) { + const owner = liveSocket2.owner(el); + js_default.show( + eventType, + owner, + el, + opts.display, + js_default.transitionClasses(opts.transition), + opts.time, + opts.blocking + ); + }, + hide(el, opts = {}) { + const owner = liveSocket2.owner(el); + js_default.hide( + eventType, + owner, + el, + null, + js_default.transitionClasses(opts.transition), + opts.time, + opts.blocking + ); + }, + toggle(el, opts = {}) { + const owner = liveSocket2.owner(el); + const inTransition = js_default.transitionClasses(opts.in); + const outTransition = js_default.transitionClasses(opts.out); + js_default.toggle( + eventType, + owner, + el, + opts.display, + inTransition, + outTransition, + opts.time, + opts.blocking + ); + }, + addClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket2.owner(el); + js_default.addOrRemoveClasses( + el, + classNames, + [], + js_default.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking + ); + }, + removeClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket2.owner(el); + js_default.addOrRemoveClasses( + el, + [], + classNames, + js_default.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking + ); + }, + toggleClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket2.owner(el); + js_default.toggleClasses( + el, + classNames, + js_default.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking + ); + }, + transition(el, transition, opts = {}) { + const owner = liveSocket2.owner(el); + js_default.addOrRemoveClasses( + el, + [], + [], + js_default.transitionClasses(transition), + opts.time, + owner, + opts.blocking + ); + }, + setAttribute(el, attr, val) { + js_default.setOrRemoveAttrs(el, [[attr, val]], []); + }, + removeAttribute(el, attr) { + js_default.setOrRemoveAttrs(el, [], [attr]); + }, + toggleAttribute(el, attr, val1, val2) { + js_default.toggleAttr(el, attr, val1, val2); + }, + push(el, type, opts = {}) { + liveSocket2.withinOwners(el, (view) => { + const data = opts.value || {}; + delete opts.value; + let e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); + js_default.exec(e, eventType, type, view, el, ["push", { data, ...opts }]); + }); + }, + navigate(href, opts = {}) { + const customEvent = new CustomEvent("phx:exec"); + liveSocket2.historyRedirect( + customEvent, + href, + opts.replace ? "replace" : "push", + null, + null + ); + }, + patch(href, opts = {}) { + const customEvent = new CustomEvent("phx:exec"); + liveSocket2.pushHistoryPatch( + customEvent, + href, + opts.replace ? "replace" : "push", + null + ); + }, + ignoreAttributes(el, attrs) { + js_default.ignoreAttrs(el, Array.isArray(attrs) ? attrs : [attrs]); + } + }; + }; + var HOOK_ID = "hookId"; + var DEAD_HOOK = "deadHook"; + var viewHookID = 1; + var ViewHook = class _ViewHook { + get liveSocket() { + return this.__liveSocket(); + } + static makeID() { + return viewHookID++; + } + static elementID(el) { + return dom_default.private(el, HOOK_ID); + } + static deadHook(el) { + return dom_default.private(el, DEAD_HOOK) === true; + } + constructor(view, el, callbacks) { + this.el = el; + this.__attachView(view); + this.__listeners = /* @__PURE__ */ new Set(); + this.__isDisconnected = false; + dom_default.putPrivate(this.el, HOOK_ID, _ViewHook.makeID()); + if (view && view.isDead) { + dom_default.putPrivate(this.el, DEAD_HOOK, true); + } + if (callbacks) { + const protectedProps = /* @__PURE__ */ new Set([ + "el", + "liveSocket", + "__view", + "__listeners", + "__isDisconnected", + "constructor", + // Standard object properties + // Core ViewHook API methods + "js", + "pushEvent", + "pushEventTo", + "handleEvent", + "removeHandleEvent", + "upload", + "uploadTo", + // Internal lifecycle callers + "__mounted", + "__updated", + "__beforeUpdate", + "__destroyed", + "__reconnected", + "__disconnected", + "__cleanup__" + ]); + for (const key in callbacks) { + if (Object.prototype.hasOwnProperty.call(callbacks, key)) { + this[key] = callbacks[key]; + if (protectedProps.has(key)) { + console.warn( + `Hook object for element #${el.id} overwrites core property '${key}'!` + ); + } + } + } + const lifecycleMethods = [ + "mounted", + "beforeUpdate", + "updated", + "destroyed", + "disconnected", + "reconnected" + ]; + lifecycleMethods.forEach((methodName) => { + if (callbacks[methodName] && typeof callbacks[methodName] === "function") { + this[methodName] = callbacks[methodName]; + } + }); + } + } + /** @internal */ + __attachView(view) { + if (view) { + this.__view = () => view; + this.__liveSocket = () => view.liveSocket; + } else { + this.__view = () => { + throw new Error( + `hook not yet attached to a live view: ${this.el.outerHTML}` + ); + }; + this.__liveSocket = () => { + throw new Error( + `hook not yet attached to a live view: ${this.el.outerHTML}` + ); + }; + } + } + // Default lifecycle methods + mounted() { + } + beforeUpdate() { + } + updated() { + } + destroyed() { + } + disconnected() { + } + reconnected() { + } + // Internal lifecycle callers - called by the View + /** @internal */ + __mounted() { + this.mounted(); + } + /** @internal */ + __updated() { + this.updated(); + } + /** @internal */ + __beforeUpdate() { + this.beforeUpdate(); + } + /** @internal */ + __destroyed() { + this.destroyed(); + dom_default.deletePrivate(this.el, HOOK_ID); + } + /** @internal */ + __reconnected() { + if (this.__isDisconnected) { + this.__isDisconnected = false; + this.reconnected(); + } + } + /** @internal */ + __disconnected() { + this.__isDisconnected = true; + this.disconnected(); + } + js() { + return { + ...js_commands_default(this.__view().liveSocket, "hook"), + exec: (encodedJS) => { + this.__view().liveSocket.execJS(this.el, encodedJS, "hook"); + } + }; + } + pushEvent(event, payload, onReply) { + const promise = this.__view().pushHookEvent( + this.el, + null, + event, + payload || {} + ); + if (onReply === void 0) { + return promise.then(({ reply }) => reply); + } + promise.then( + ({ reply, ref }) => onReply(reply, ref) + ).catch(() => { + }); + } + pushEventTo(selectorOrTarget, event, payload, onReply) { + if (onReply === void 0) { + const targetPair = []; + this.__view().withinTargets( + selectorOrTarget, + (view, targetCtx) => { + targetPair.push({ view, targetCtx }); + } + ); + const promises = targetPair.map(({ view, targetCtx }) => { + return view.pushHookEvent(this.el, targetCtx, event, payload || {}); + }); + return Promise.allSettled(promises); + } + this.__view().withinTargets( + selectorOrTarget, + (view, targetCtx) => { + view.pushHookEvent(this.el, targetCtx, event, payload || {}).then( + ({ reply, ref }) => onReply(reply, ref) + ).catch(() => { + }); + } + ); + } + handleEvent(event, callback) { + const callbackRef = { + event, + callback: (customEvent) => callback(customEvent.detail) + }; + window.addEventListener( + `phx:${event}`, + callbackRef.callback + ); + this.__listeners.add(callbackRef); + return callbackRef; + } + removeHandleEvent(ref) { + window.removeEventListener( + `phx:${ref.event}`, + ref.callback + ); + this.__listeners.delete(ref); + } + upload(name, files) { + return this.__view().dispatchUploads(null, name, files); + } + uploadTo(selectorOrTarget, name, files) { + return this.__view().withinTargets( + selectorOrTarget, + (view, targetCtx) => { + view.dispatchUploads(targetCtx, name, files); + } + ); + } + /** @internal */ + __cleanup__() { + this.__listeners.forEach( + (callbackRef) => this.removeHandleEvent(callbackRef) + ); + } + }; + var prependFormDataKey = (key, prefix) => { + const isArray = key.endsWith("[]"); + let baseKey = isArray ? key.slice(0, -2) : key; + baseKey = baseKey.replace(/([^\[\]]+)(\]?$)/, `${prefix}$1$2`); + if (isArray) { + baseKey += "[]"; + } + return baseKey; + }; + var serializeForm = (form, opts, onlyNames = []) => { + const { submitter } = opts; + let injectedElement; + if (submitter && submitter.name) { + const input = document.createElement("input"); + input.type = "hidden"; + const formId = submitter.getAttribute("form"); + if (formId) { + input.setAttribute("form", formId); + } + input.name = submitter.name; + input.value = submitter.value; + submitter.parentElement.insertBefore(input, submitter); + injectedElement = input; + } + const formData = new FormData(form); + const toRemove = []; + formData.forEach((val, key, _index) => { + if (val instanceof File) { + toRemove.push(key); + } + }); + toRemove.forEach((key) => formData.delete(key)); + const params = new URLSearchParams(); + const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce( + (acc, input) => { + const { inputsUnused: inputsUnused2, onlyHiddenInputs: onlyHiddenInputs2 } = acc; + const key = input.name; + if (!key) { + return acc; + } + if (inputsUnused2[key] === void 0) { + inputsUnused2[key] = true; + } + if (onlyHiddenInputs2[key] === void 0) { + onlyHiddenInputs2[key] = true; + } + const isUsed = dom_default.private(input, PHX_HAS_FOCUSED) || dom_default.private(input, PHX_HAS_SUBMITTED); + const isHidden = input.type === "hidden"; + inputsUnused2[key] = inputsUnused2[key] && !isUsed; + onlyHiddenInputs2[key] = onlyHiddenInputs2[key] && isHidden; + return acc; + }, + { inputsUnused: {}, onlyHiddenInputs: {} } + ); + for (const [key, val] of formData.entries()) { + if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) { + const isUnused = inputsUnused[key]; + const hidden = onlyHiddenInputs[key]; + if (isUnused && !(submitter && submitter.name == key) && !hidden) { + params.append(prependFormDataKey(key, "_unused_"), ""); + } + if (typeof val === "string") { + params.append(key, val); + } + } + } + if (submitter && injectedElement) { + submitter.parentElement.removeChild(injectedElement); + } + return params.toString(); + }; + var View = class _View { + static closestView(el) { + const liveViewEl = el.closest(PHX_VIEW_SELECTOR); + return liveViewEl ? dom_default.private(liveViewEl, "view") : null; + } + constructor(el, liveSocket2, parentView, flash, liveReferer) { + this.isDead = false; + this.liveSocket = liveSocket2; + this.flash = flash; + this.parent = parentView; + this.root = parentView ? parentView.root : this; + this.el = el; + const boundView = dom_default.private(this.el, "view"); + if (boundView !== void 0 && boundView.isDead !== true) { + logError( + `The DOM element for this view has already been bound to a view. + + An element can only ever be associated with a single view! + Please ensure that you are not trying to initialize multiple LiveSockets on the same page. + This could happen if you're accidentally trying to render your root layout more than once. + Ensure that the template set on the LiveView is different than the root layout. + `, + { view: boundView } + ); + throw new Error("Cannot bind multiple views to the same DOM element."); + } + dom_default.putPrivate(this.el, "view", this); + this.id = this.el.id; + this.el.setAttribute(PHX_ROOT_ID, this.root.id); + this.ref = 0; + this.lastAckRef = null; + this.childJoins = 0; + this.loaderTimer = null; + this.disconnectedTimer = null; + this.pendingDiffs = []; + this.pendingForms = /* @__PURE__ */ new Set(); + this.redirect = false; + this.href = null; + this.joinCount = this.parent ? this.parent.joinCount - 1 : 0; + this.joinAttempts = 0; + this.joinPending = true; + this.destroyed = false; + this.joinCallback = function(onDone) { + onDone && onDone(); + }; + this.stopCallback = function() { + }; + this.pendingJoinOps = []; + this.viewHooks = {}; + this.formSubmits = []; + this.children = this.parent ? null : {}; + this.root.children[this.id] = {}; + this.formsForRecovery = {}; + this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { + const url = this.href && this.expandURL(this.href); + return { + redirect: this.redirect ? url : void 0, + url: this.redirect ? void 0 : url || void 0, + params: this.connectParams(liveReferer), + session: this.getSession(), + static: this.getStatic(), + flash: this.flash, + sticky: this.el.hasAttribute(PHX_STICKY) + }; + }); + this.portalElementIds = /* @__PURE__ */ new Set(); + } + setHref(href) { + this.href = href; + } + setRedirect(href) { + this.redirect = true; + this.href = href; + } + isMain() { + return this.el.hasAttribute(PHX_MAIN); + } + connectParams(liveReferer) { + const params = this.liveSocket.params(this.el); + const manifest = dom_default.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`).map((node) => node.src || node.href).filter((url) => typeof url === "string"); + if (manifest.length > 0) { + params["_track_static"] = manifest; + } + params["_mounts"] = this.joinCount; + params["_mount_attempts"] = this.joinAttempts; + params["_live_referer"] = liveReferer; + this.joinAttempts++; + return params; + } + isConnected() { + return this.channel.canPush(); + } + getSession() { + return this.el.getAttribute(PHX_SESSION); + } + getStatic() { + const val = this.el.getAttribute(PHX_STATIC); + return val === "" ? null : val; + } + destroy(callback = function() { + }) { + this.destroyAllChildren(); + this.destroyPortalElements(); + this.destroyed = true; + dom_default.deletePrivate(this.el, "view"); + delete this.root.children[this.id]; + if (this.parent) { + delete this.root.children[this.parent.id][this.id]; + } + clearTimeout(this.loaderTimer); + const onFinished = () => { + callback(); + for (const id in this.viewHooks) { + this.destroyHook(this.viewHooks[id]); + } + }; + dom_default.markPhxChildDestroyed(this.el); + this.log("destroyed", () => ["the child has been removed from the parent"]); + this.channel.leave().receive("ok", onFinished).receive("error", onFinished).receive("timeout", onFinished); + } + setContainerClasses(...classes) { + this.el.classList.remove( + PHX_CONNECTED_CLASS, + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_CLIENT_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS + ); + this.el.classList.add(...classes); + } + showLoader(timeout) { + clearTimeout(this.loaderTimer); + if (timeout) { + this.loaderTimer = setTimeout(() => this.showLoader(), timeout); + } else { + for (const id in this.viewHooks) { + this.viewHooks[id].__disconnected(); + } + this.setContainerClasses(PHX_LOADING_CLASS); + } + } + execAll(binding) { + dom_default.all( + this.el, + `[${binding}]`, + (el) => this.liveSocket.execJS(el, el.getAttribute(binding)) + ); + } + hideLoader() { + clearTimeout(this.loaderTimer); + clearTimeout(this.disconnectedTimer); + this.setContainerClasses(PHX_CONNECTED_CLASS); + this.execAll(this.binding("connected")); + } + triggerReconnected() { + for (const id in this.viewHooks) { + this.viewHooks[id].__reconnected(); + } + } + log(kind, msgCallback) { + this.liveSocket.log(this, kind, msgCallback); + } + transition(time, onStart, onDone = function() { + }) { + this.liveSocket.transition(time, onStart, onDone); + } + // calls the callback with the view and target element for the given phxTarget + // targets can be: + // * an element itself, then it is simply passed to liveSocket.owner; + // * a CID (Component ID), then we first search the component's element in the DOM + // * a selector, then we search the selector in the DOM and call the callback + // for each element found with the corresponding owner view + withinTargets(phxTarget, callback, dom = document) { + if (phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement) { + return this.liveSocket.owner( + phxTarget, + (view) => callback(view, phxTarget) + ); + } + if (isCid(phxTarget)) { + const targets = dom_default.findComponentNodeList(this.id, phxTarget, dom); + if (targets.length === 0) { + logError(`no component found matching phx-target of ${phxTarget}`); + } else { + callback(this, parseInt(phxTarget)); + } + } else { + const targets = Array.from(dom.querySelectorAll(phxTarget)); + if (targets.length === 0) { + logError( + `nothing found matching the phx-target selector "${phxTarget}"` + ); + } + targets.forEach( + (target) => this.liveSocket.owner(target, (view) => callback(view, target)) + ); + } + } + applyDiff(type, rawDiff, callback) { + this.log(type, () => ["", clone(rawDiff)]); + const { diff, reply, events, title } = Rendered.extract(rawDiff); + const ev = events.reduce( + (acc, args) => { + if (args.length === 3 && args[2] == true) { + acc.pre.push(args.slice(0, -1)); + } else { + acc.post.push(args); + } + return acc; + }, + { pre: [], post: [] } + ); + this.liveSocket.dispatchEvents(ev.pre); + const update = () => { + callback({ diff, reply, events: ev.post }); + if (typeof title === "string" || type == "mount" && this.isMain()) { + window.requestAnimationFrame(() => dom_default.putTitle(title)); + } + }; + if ("onDocumentPatch" in this.liveSocket.domCallbacks) { + this.liveSocket.triggerDOM("onDocumentPatch", [update]); + } else { + update(); + } + } + onJoin(resp) { + const { rendered, container, liveview_version, pid } = resp; + if (container) { + const [tag, attrs] = container; + this.el = dom_default.replaceRootContainer(this.el, tag, attrs); + } + this.childJoins = 0; + this.joinPending = true; + this.flash = null; + if (this.root === this) { + this.formsForRecovery = this.getFormsForRecovery(); + } + if (this.isMain() && window.history.state === null) { + browser_default.pushState("replace", { + type: "patch", + id: this.id, + position: this.liveSocket.currentHistoryPosition + }); + } + if (liveview_version !== this.liveSocket.version()) { + console.warn( + `LiveView asset version mismatch. JavaScript version ${this.liveSocket.version()} vs. server ${liveview_version}. To avoid issues, please ensure that your assets use the same version as the server.` + ); + } + if (pid) { + this.el.setAttribute(PHX_LV_PID, pid); + } + browser_default.dropLocal( + this.liveSocket.localStorage, + window.location.pathname, + CONSECUTIVE_RELOADS + ); + this.applyDiff("mount", rendered, ({ diff, events }) => { + this.rendered = new Rendered(this.id, diff); + const [html, streams] = this.renderContainer(null, "join"); + this.dropPendingRefs(); + this.joinCount++; + this.joinAttempts = 0; + this.maybeRecoverForms(html, () => { + this.onJoinComplete(resp, html, streams, events); + }); + }); + } + dropPendingRefs() { + dom_default.all(document, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (el) => { + el.removeAttribute(PHX_REF_LOADING); + el.removeAttribute(PHX_REF_SRC); + el.removeAttribute(PHX_REF_LOCK); + }); + } + onJoinComplete({ live_patch }, html, streams, events) { + if (this.joinCount > 1 || this.parent && !this.parent.isJoinPending()) { + return this.applyJoinPatch(live_patch, html, streams, events); + } + const newChildren = dom_default.findPhxChildrenInFragment(html, this.id).filter( + (toEl) => { + const fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`); + const phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC); + if (phxStatic) { + toEl.setAttribute(PHX_STATIC, phxStatic); + } + if (fromEl) { + fromEl.setAttribute(PHX_ROOT_ID, this.root.id); + } + return this.joinChild(toEl); + } + ); + if (newChildren.length === 0) { + if (this.parent) { + this.root.pendingJoinOps.push([ + this, + () => this.applyJoinPatch(live_patch, html, streams, events) + ]); + this.parent.ackJoin(this); + } else { + this.onAllChildJoinsComplete(); + this.applyJoinPatch(live_patch, html, streams, events); + } + } else { + this.root.pendingJoinOps.push([ + this, + () => this.applyJoinPatch(live_patch, html, streams, events) + ]); + } + } + attachTrueDocEl() { + this.el = dom_default.byId(this.id); + this.el.setAttribute(PHX_ROOT_ID, this.root.id); + } + // this is invoked for dead and live views, so we must filter by + // by owner to ensure we aren't duplicating hooks across disconnect + // and connected states. This also handles cases where hooks exist + // in a root layout with a LV in the body + execNewMounted(parent = document) { + let phxViewportTop = this.binding(PHX_VIEWPORT_TOP); + let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); + this.all( + parent, + `[${phxViewportTop}], [${phxViewportBottom}]`, + (hookEl) => { + dom_default.maintainPrivateHooks( + hookEl, + hookEl, + phxViewportTop, + phxViewportBottom + ); + this.maybeAddNewHook(hookEl); + } + ); + this.all( + parent, + `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, + (hookEl) => { + this.maybeAddNewHook(hookEl); + } + ); + this.all(parent, `[${this.binding(PHX_MOUNTED)}]`, (el) => { + this.maybeMounted(el); + }); + } + all(parent, selector, callback) { + dom_default.all(parent, selector, (el) => { + if (this.ownsElement(el)) { + callback(el); + } + }); + } + applyJoinPatch(live_patch, html, streams, events) { + if (this.joinCount > 1) { + if (this.pendingJoinOps.length) { + this.pendingJoinOps.forEach((cb) => typeof cb === "function" && cb()); + this.pendingJoinOps = []; + } + } + this.attachTrueDocEl(); + const patch = new DOMPatch(this, this.el, this.id, html, streams, null); + patch.markPrunableContentForRemoval(); + this.performPatch(patch, false, true); + this.joinNewChildren(); + this.execNewMounted(); + this.joinPending = false; + this.liveSocket.dispatchEvents(events); + this.applyPendingUpdates(); + if (live_patch) { + const { kind, to } = live_patch; + this.liveSocket.historyPatch(to, kind); + } + this.hideLoader(); + if (this.joinCount > 1) { + this.triggerReconnected(); + } + this.stopCallback(); + } + triggerBeforeUpdateHook(fromEl, toEl) { + this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]); + const hook = this.getHook(fromEl); + const isIgnored = hook && dom_default.isIgnored(fromEl, this.binding(PHX_UPDATE)); + if (hook && !fromEl.isEqualNode(toEl) && !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))) { + hook.__beforeUpdate(); + return hook; + } + } + maybeMounted(el) { + const phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)); + const hasBeenInvoked = phxMounted && dom_default.private(el, "mounted"); + if (phxMounted && !hasBeenInvoked) { + this.liveSocket.execJS(el, phxMounted); + dom_default.putPrivate(el, "mounted", true); + } + } + maybeAddNewHook(el) { + const newHook = this.addHook(el); + if (newHook) { + newHook.__mounted(); + } + } + performPatch(patch, pruneCids, isJoinPatch = false) { + const removedEls = []; + let phxChildrenAdded = false; + const updatedHookIds = /* @__PURE__ */ new Set(); + this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]); + patch.after("added", (el) => { + this.liveSocket.triggerDOM("onNodeAdded", [el]); + const phxViewportTop = this.binding(PHX_VIEWPORT_TOP); + const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); + dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.maybeAddNewHook(el); + if (el.getAttribute) { + this.maybeMounted(el); + } + }); + patch.after("phxChildAdded", (el) => { + if (dom_default.isPhxSticky(el)) { + this.liveSocket.joinRootViews(); + } else { + phxChildrenAdded = true; + } + }); + patch.before("updated", (fromEl, toEl) => { + const hook = this.triggerBeforeUpdateHook(fromEl, toEl); + if (hook) { + updatedHookIds.add(fromEl.id); + } + js_default.onBeforeElUpdated(fromEl, toEl); + }); + patch.after("updated", (el) => { + if (updatedHookIds.has(el.id)) { + const hook = this.getHook(el); + hook && hook.__updated(); + } + }); + patch.after("discarded", (el) => { + if (el.nodeType === Node.ELEMENT_NODE) { + removedEls.push(el); + } + }); + patch.after( + "transitionsDiscarded", + (els) => this.afterElementsRemoved(els, pruneCids) + ); + patch.perform(isJoinPatch); + this.afterElementsRemoved(removedEls, pruneCids); + this.liveSocket.triggerDOM("onPatchEnd", [patch.targetContainer]); + return phxChildrenAdded; + } + afterElementsRemoved(elements, pruneCids) { + const destroyedCIDs = []; + elements.forEach((parent) => { + const components = dom_default.all( + parent, + `[${PHX_VIEW_REF}="${this.id}"][${PHX_COMPONENT}]` + ); + const hooks = dom_default.all( + parent, + `[${this.binding(PHX_HOOK)}], [data-phx-hook]` + ); + components.concat(parent).forEach((el) => { + const cid = this.componentID(el); + if (isCid(cid) && destroyedCIDs.indexOf(cid) === -1 && el.getAttribute(PHX_VIEW_REF) === this.id) { + destroyedCIDs.push(cid); + } + }); + hooks.concat(parent).forEach((hookEl) => { + const hook = this.getHook(hookEl); + hook && this.destroyHook(hook); + }); + }); + if (pruneCids) { + this.maybePushComponentsDestroyed(destroyedCIDs); + } + } + joinNewChildren() { + dom_default.findPhxChildren(document, this.id).forEach((el) => this.joinChild(el)); + } + maybeRecoverForms(html, callback) { + const phxChange = this.binding("change"); + const oldForms = this.root.formsForRecovery; + const template = document.createElement("template"); + template.innerHTML = html; + dom_default.all(template.content, `[${PHX_PORTAL}]`).forEach((portalTemplate) => { + template.content.firstElementChild.appendChild( + portalTemplate.content.firstElementChild + ); + }); + const rootEl = template.content.firstElementChild; + rootEl.id = this.id; + rootEl.setAttribute(PHX_ROOT_ID, this.root.id); + rootEl.setAttribute(PHX_SESSION, this.getSession()); + rootEl.setAttribute(PHX_STATIC, this.getStatic()); + rootEl.setAttribute(PHX_PARENT_ID, this.parent ? this.parent.id : null); + const formsToRecover = ( + // we go over all forms in the new DOM; because this is only the HTML for the current + // view, we can be sure that all forms are owned by this view: + dom_default.all(template.content, "form").filter((newForm) => newForm.id && oldForms[newForm.id]).filter((newForm) => !this.pendingForms.has(newForm.id)).filter( + (newForm) => oldForms[newForm.id].getAttribute(phxChange) === newForm.getAttribute(phxChange) + ).map((newForm) => { + return [oldForms[newForm.id], newForm]; + }) + ); + if (formsToRecover.length === 0) { + return callback(); + } + formsToRecover.forEach(([oldForm, newForm], i) => { + this.pendingForms.add(newForm.id); + this.pushFormRecovery( + oldForm, + newForm, + template.content.firstElementChild, + () => { + this.pendingForms.delete(newForm.id); + if (i === formsToRecover.length - 1) { + callback(); + } + } + ); + }); + } + getChildById(id) { + return this.root.children[this.id][id]; + } + getDescendentByEl(el) { + if (el.id === this.id) { + return this; + } else { + return this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id]; + } + } + destroyDescendent(id) { + for (const parentId in this.root.children) { + for (const childId in this.root.children[parentId]) { + if (childId === id) { + return this.root.children[parentId][childId].destroy(); + } + } + } + } + joinChild(el) { + const child = this.getChildById(el.id); + if (!child) { + const view = new _View(el, this.liveSocket, this); + this.root.children[this.id][view.id] = view; + view.join(); + this.childJoins++; + return true; + } + } + isJoinPending() { + return this.joinPending; + } + ackJoin(_child) { + this.childJoins--; + if (this.childJoins === 0) { + if (this.parent) { + this.parent.ackJoin(this); + } else { + this.onAllChildJoinsComplete(); + } + } + } + onAllChildJoinsComplete() { + this.pendingForms.clear(); + this.formsForRecovery = {}; + this.joinCallback(() => { + this.pendingJoinOps.forEach(([view, op]) => { + if (!view.isDestroyed()) { + op(); + } + }); + this.pendingJoinOps = []; + }); + } + update(diff, events, isPending = false) { + if (this.isJoinPending() || this.liveSocket.hasPendingLink() && this.root.isMain()) { + if (!isPending) { + this.pendingDiffs.push({ diff, events }); + } + return false; + } + this.rendered.mergeDiff(diff); + let phxChildrenAdded = false; + if (this.rendered.isComponentOnlyDiff(diff)) { + this.liveSocket.time("component patch complete", () => { + const parentCids = dom_default.findExistingParentCIDs( + this.id, + this.rendered.componentCIDs(diff) + ); + parentCids.forEach((parentCID) => { + if (this.componentPatch( + this.rendered.getComponent(diff, parentCID), + parentCID + )) { + phxChildrenAdded = true; + } + }); + }); + } else if (!isEmpty(diff)) { + this.liveSocket.time("full patch complete", () => { + const [html, streams] = this.renderContainer(diff, "update"); + const patch = new DOMPatch(this, this.el, this.id, html, streams, null); + phxChildrenAdded = this.performPatch(patch, true); + }); + } + this.liveSocket.dispatchEvents(events); + if (phxChildrenAdded) { + this.joinNewChildren(); + } + return true; + } + renderContainer(diff, kind) { + return this.liveSocket.time(`toString diff (${kind})`, () => { + const tag = this.el.tagName; + const cids = diff ? this.rendered.componentCIDs(diff) : null; + const { buffer: html, streams } = this.rendered.toString(cids); + return [`<${tag}>${html}`, streams]; + }); + } + componentPatch(diff, cid) { + if (isEmpty(diff)) + return false; + const { buffer: html, streams } = this.rendered.componentToString(cid); + const patch = new DOMPatch(this, this.el, this.id, html, streams, cid); + const childrenAdded = this.performPatch(patch, true); + return childrenAdded; + } + getHook(el) { + return this.viewHooks[ViewHook.elementID(el)]; + } + addHook(el) { + const hookElId = ViewHook.elementID(el); + if (el.getAttribute && !this.ownsElement(el)) { + return; + } + if (hookElId && !this.viewHooks[hookElId]) { + if (ViewHook.deadHook(el)) { + return; + } + const hook = dom_default.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`); + this.viewHooks[hookElId] = hook; + hook.__attachView(this); + return hook; + } else if (hookElId || !el.getAttribute) { + return; + } else { + const hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)); + if (!hookName) { + return; + } + const hookDefinition = this.liveSocket.getHookDefinition(hookName); + if (hookDefinition) { + if (!el.id) { + logError( + `no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, + el + ); + return; + } + let hookInstance; + try { + if (typeof hookDefinition === "function" && hookDefinition.prototype instanceof ViewHook) { + hookInstance = new hookDefinition(this, el); + } else if (typeof hookDefinition === "object" && hookDefinition !== null) { + hookInstance = new ViewHook(this, el, hookDefinition); + } else { + logError( + `Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, + el + ); + return; + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logError(`Failed to create hook "${hookName}": ${errorMessage}`, el); + return; + } + this.viewHooks[ViewHook.elementID(hookInstance.el)] = hookInstance; + return hookInstance; + } else if (hookName !== null) { + logError(`unknown hook found for "${hookName}"`, el); + } + } + } + destroyHook(hook) { + const hookId = ViewHook.elementID(hook.el); + hook.__destroyed(); + hook.__cleanup__(); + delete this.viewHooks[hookId]; + } + applyPendingUpdates() { + this.pendingDiffs = this.pendingDiffs.filter( + ({ diff, events }) => !this.update(diff, events, true) + ); + this.eachChild((child) => child.applyPendingUpdates()); + } + eachChild(callback) { + const children = this.root.children[this.id] || {}; + for (const id in children) { + callback(this.getChildById(id)); + } + } + onChannel(event, cb) { + this.liveSocket.onChannel(this.channel, event, (resp) => { + if (this.isJoinPending()) { + if (this.joinCount > 1) { + this.pendingJoinOps.push(() => cb(resp)); + } else { + this.root.pendingJoinOps.push([this, () => cb(resp)]); + } + } else { + this.liveSocket.requestDOMUpdate(() => cb(resp)); + } + }); + } + bindChannel() { + this.liveSocket.onChannel(this.channel, "diff", (rawDiff) => { + this.liveSocket.requestDOMUpdate(() => { + this.applyDiff( + "update", + rawDiff, + ({ diff, events }) => this.update(diff, events) + ); + }); + }); + this.onChannel( + "redirect", + ({ to, flash }) => this.onRedirect({ to, flash }) + ); + this.onChannel("live_patch", (redir) => this.onLivePatch(redir)); + this.onChannel("live_redirect", (redir) => this.onLiveRedirect(redir)); + this.channel.onError((reason) => this.onError(reason)); + this.channel.onClose((reason) => this.onClose(reason)); + } + destroyAllChildren() { + this.eachChild((child) => child.destroy()); + } + onLiveRedirect(redir) { + const { to, kind, flash } = redir; + const url = this.expandURL(to); + const e = new CustomEvent("phx:server-navigate", { + detail: { to, kind, flash } + }); + this.liveSocket.historyRedirect(e, url, kind, flash); + } + onLivePatch(redir) { + const { to, kind } = redir; + this.href = this.expandURL(to); + this.liveSocket.historyPatch(to, kind); + } + expandURL(to) { + return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to; + } + /** + * @param {{to: string, flash?: string, reloadToken?: string}} redirect + */ + onRedirect({ to, flash, reloadToken }) { + this.liveSocket.redirect(to, flash, reloadToken); + } + isDestroyed() { + return this.destroyed; + } + joinDead() { + this.isDead = true; + } + joinPush() { + this.joinPush = this.joinPush || this.channel.join(); + return this.joinPush; + } + join(callback) { + this.showLoader(this.liveSocket.loaderTimeout); + this.bindChannel(); + if (this.isMain()) { + this.stopCallback = this.liveSocket.withPageLoading({ + to: this.href, + kind: "initial" + }); + } + this.joinCallback = (onDone) => { + onDone = onDone || function() { + }; + callback ? callback(this.joinCount, onDone) : onDone(); + }; + this.wrapPush(() => this.channel.join(), { + ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), + error: (error) => this.onJoinError(error), + timeout: () => this.onJoinError({ reason: "timeout" }) + }); + } + onJoinError(resp) { + if (resp.reason === "reload") { + this.log("error", () => [ + `failed mount with ${resp.status}. Falling back to page reload`, + resp + ]); + this.onRedirect({ + to: this.liveSocket.main.href, + reloadToken: resp.token + }); + return; + } else if (resp.reason === "unauthorized" || resp.reason === "stale") { + this.log("error", () => [ + "unauthorized live_redirect. Falling back to page request", + resp + ]); + this.onRedirect({ to: this.liveSocket.main.href, flash: this.flash }); + return; + } + if (resp.redirect || resp.live_redirect) { + this.joinPending = false; + this.channel.leave(); + } + if (resp.redirect) { + return this.onRedirect(resp.redirect); + } + if (resp.live_redirect) { + return this.onLiveRedirect(resp.live_redirect); + } + this.log("error", () => ["unable to join", resp]); + if (this.isMain()) { + this.displayError( + [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], + { unstructuredError: resp, errorKind: "server" } + ); + if (this.liveSocket.isConnected()) { + this.liveSocket.reloadWithJitter(this); + } + } else { + if (this.joinAttempts >= MAX_CHILD_JOIN_ATTEMPTS) { + this.root.displayError( + [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], + { unstructuredError: resp, errorKind: "server" } + ); + this.log("error", () => [ + `giving up trying to mount after ${MAX_CHILD_JOIN_ATTEMPTS} tries`, + resp + ]); + this.destroy(); + } + const trueChildEl = dom_default.byId(this.el.id); + if (trueChildEl) { + dom_default.mergeAttrs(trueChildEl, this.el); + this.displayError( + [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], + { unstructuredError: resp, errorKind: "server" } + ); + this.el = trueChildEl; + } else { + this.destroy(); + } + } + } + onClose(reason) { + if (this.isDestroyed()) { + return; + } + if (this.isMain() && this.liveSocket.hasPendingLink() && reason !== "leave") { + return this.liveSocket.reloadWithJitter(this); + } + this.destroyAllChildren(); + this.liveSocket.dropActiveElement(this); + if (this.liveSocket.isUnloaded()) { + this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT); + } + } + onError(reason) { + this.onClose(reason); + if (this.liveSocket.isConnected()) { + this.log("error", () => ["view crashed", reason]); + } + if (!this.liveSocket.isUnloaded()) { + if (this.liveSocket.isConnected()) { + this.displayError( + [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], + { unstructuredError: reason, errorKind: "server" } + ); + } else { + this.displayError( + [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS], + { unstructuredError: reason, errorKind: "client" } + ); + } + } + } + displayError(classes, details = {}) { + if (this.isMain()) { + dom_default.dispatchEvent(window, "phx:page-loading-start", { + detail: { to: this.href, kind: "error", ...details } + }); + } + this.showLoader(); + this.setContainerClasses(...classes); + this.delayedDisconnected(); + } + delayedDisconnected() { + this.disconnectedTimer = setTimeout(() => { + this.execAll(this.binding("disconnected")); + }, this.liveSocket.disconnectedTimeout); + } + wrapPush(callerPush, receives) { + const latency = this.liveSocket.getLatencySim(); + const withLatency = latency ? (cb) => setTimeout(() => !this.isDestroyed() && cb(), latency) : (cb) => !this.isDestroyed() && cb(); + withLatency(() => { + callerPush().receive( + "ok", + (resp) => withLatency(() => receives.ok && receives.ok(resp)) + ).receive( + "error", + (reason) => withLatency(() => receives.error && receives.error(reason)) + ).receive( + "timeout", + () => withLatency(() => receives.timeout && receives.timeout()) + ); + }); + } + pushWithReply(refGenerator, event, payload) { + if (!this.isConnected()) { + return Promise.reject(new Error("no connection")); + } + const [ref, [el], opts] = refGenerator ? refGenerator({ payload }) : [null, [], {}]; + const oldJoinCount = this.joinCount; + let onLoadingDone = function() { + }; + if (opts.page_loading) { + onLoadingDone = this.liveSocket.withPageLoading({ + kind: "element", + target: el + }); + } + if (typeof payload.cid !== "number") { + delete payload.cid; + } + return new Promise((resolve, reject) => { + this.wrapPush(() => this.channel.push(event, payload, PUSH_TIMEOUT), { + ok: (resp) => { + if (ref !== null) { + this.lastAckRef = ref; + } + const finish = (hookReply) => { + if (resp.redirect) { + this.onRedirect(resp.redirect); + } + if (resp.live_patch) { + this.onLivePatch(resp.live_patch); + } + if (resp.live_redirect) { + this.onLiveRedirect(resp.live_redirect); + } + onLoadingDone(); + resolve({ resp, reply: hookReply, ref }); + }; + if (resp.diff) { + this.liveSocket.requestDOMUpdate(() => { + this.applyDiff("update", resp.diff, ({ diff, reply, events }) => { + if (ref !== null) { + this.undoRefs(ref, payload.event); + } + this.update(diff, events); + finish(reply); + }); + }); + } else { + if (ref !== null) { + this.undoRefs(ref, payload.event); + } + finish(null); + } + }, + error: (reason) => reject(new Error(`failed with reason: ${JSON.stringify(reason)}`)), + timeout: () => { + reject(new Error("timeout")); + if (this.joinCount === oldJoinCount) { + this.liveSocket.reloadWithJitter(this, () => { + this.log("timeout", () => [ + "received timeout while communicating with server. Falling back to hard refresh for recovery" + ]); + }); + } + } + }); + }); + } + undoRefs(ref, phxEvent, onlyEls) { + if (!this.isConnected()) { + return; + } + const selector = `[${PHX_REF_SRC}="${this.refSrc()}"]`; + if (onlyEls) { + onlyEls = new Set(onlyEls); + dom_default.all(document, selector, (parent) => { + if (onlyEls && !onlyEls.has(parent)) { + return; + } + dom_default.all( + parent, + selector, + (child) => this.undoElRef(child, ref, phxEvent) + ); + this.undoElRef(parent, ref, phxEvent); + }); + } else { + dom_default.all(document, selector, (el) => this.undoElRef(el, ref, phxEvent)); + } + } + undoElRef(el, ref, phxEvent) { + const elRef = new ElementRef(el); + elRef.maybeUndo(ref, phxEvent, (clonedTree) => { + const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, { + undoRef: ref + }); + const phxChildrenAdded = this.performPatch(patch, true); + dom_default.all( + el, + `[${PHX_REF_SRC}="${this.refSrc()}"]`, + (child) => this.undoElRef(child, ref, phxEvent) + ); + if (phxChildrenAdded) { + this.joinNewChildren(); + } + }); + } + refSrc() { + return this.el.id; + } + putRef(elements, phxEvent, eventType, opts = {}) { + const newRef = this.ref++; + const disableWith = this.binding(PHX_DISABLE_WITH); + if (opts.loading) { + const loadingEls = dom_default.all(document, opts.loading).map((el) => { + return { el, lock: true, loading: true }; + }); + elements = elements.concat(loadingEls); + } + for (const { el, lock, loading } of elements) { + if (!lock && !loading) { + throw new Error("putRef requires lock or loading"); + } + el.setAttribute(PHX_REF_SRC, this.refSrc()); + if (loading) { + el.setAttribute(PHX_REF_LOADING, newRef); + } + if (lock) { + el.setAttribute(PHX_REF_LOCK, newRef); + } + if (!loading || opts.submitter && !(el === opts.submitter || el === opts.form)) { + continue; + } + const lockCompletePromise = new Promise((resolve) => { + el.addEventListener(`phx:undo-lock:${newRef}`, () => resolve(detail), { + once: true + }); + }); + const loadingCompletePromise = new Promise((resolve) => { + el.addEventListener( + `phx:undo-loading:${newRef}`, + () => resolve(detail), + { once: true } + ); + }); + el.classList.add(`phx-${eventType}-loading`); + const disableText = el.getAttribute(disableWith); + if (disableText !== null) { + if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) { + el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.textContent); + } + if (disableText !== "") { + el.textContent = disableText; + } + el.setAttribute( + PHX_DISABLED, + el.getAttribute(PHX_DISABLED) || el.disabled + ); + el.setAttribute("disabled", ""); + } + const detail = { + event: phxEvent, + eventType, + ref: newRef, + isLoading: loading, + isLocked: lock, + lockElements: elements.filter(({ lock: lock2 }) => lock2).map(({ el: el2 }) => el2), + loadingElements: elements.filter(({ loading: loading2 }) => loading2).map(({ el: el2 }) => el2), + unlock: (els) => { + els = Array.isArray(els) ? els : [els]; + this.undoRefs(newRef, phxEvent, els); + }, + lockComplete: lockCompletePromise, + loadingComplete: loadingCompletePromise, + lock: (lockEl) => { + return new Promise((resolve) => { + if (this.isAcked(newRef)) { + return resolve(detail); + } + lockEl.setAttribute(PHX_REF_LOCK, newRef); + lockEl.setAttribute(PHX_REF_SRC, this.refSrc()); + lockEl.addEventListener( + `phx:lock-stop:${newRef}`, + () => resolve(detail), + { once: true } + ); + }); + } + }; + if (opts.payload) { + detail["payload"] = opts.payload; + } + if (opts.target) { + detail["target"] = opts.target; + } + if (opts.originalEvent) { + detail["originalEvent"] = opts.originalEvent; + } + el.dispatchEvent( + new CustomEvent("phx:push", { + detail, + bubbles: true, + cancelable: false + }) + ); + if (phxEvent) { + el.dispatchEvent( + new CustomEvent(`phx:push:${phxEvent}`, { + detail, + bubbles: true, + cancelable: false + }) + ); + } + } + return [newRef, elements.map(({ el }) => el), opts]; + } + isAcked(ref) { + return this.lastAckRef !== null && this.lastAckRef >= ref; + } + componentID(el) { + const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT); + return cid ? parseInt(cid) : null; + } + targetComponentID(target, targetCtx, opts = {}) { + if (isCid(targetCtx)) { + return targetCtx; + } + const cidOrSelector = opts.target || target.getAttribute(this.binding("target")); + if (isCid(cidOrSelector)) { + return parseInt(cidOrSelector); + } else if (targetCtx && (cidOrSelector !== null || opts.target)) { + return this.closestComponentID(targetCtx); + } else { + return null; + } + } + closestComponentID(targetCtx) { + if (isCid(targetCtx)) { + return targetCtx; + } else if (targetCtx) { + return maybe( + // We either use the closest data-phx-component binding, or - + // in case of portals - continue with the portal source. + // This is necessary if teleporting an element outside of its LiveComponent. + targetCtx.closest(`[${PHX_COMPONENT}],[${PHX_TELEPORTED_SRC}]`), + (el) => { + if (el.hasAttribute(PHX_COMPONENT)) { + return this.ownsElement(el) && this.componentID(el); + } + if (el.hasAttribute(PHX_TELEPORTED_SRC)) { + const portalParent = dom_default.byId(el.getAttribute(PHX_TELEPORTED_SRC)); + return this.closestComponentID(portalParent); + } + } + ); + } else { + return null; + } + } + pushHookEvent(el, targetCtx, event, payload) { + if (!this.isConnected()) { + this.log("hook", () => [ + "unable to push hook event. LiveView not connected", + event, + payload + ]); + return Promise.reject( + new Error("unable to push hook event. LiveView not connected") + ); + } + const refGenerator = () => this.putRef([{ el, loading: true, lock: true }], event, "hook", { + payload, + target: targetCtx + }); + return this.pushWithReply(refGenerator, "event", { + type: "hook", + event, + value: payload, + cid: this.closestComponentID(targetCtx) + }).then(({ resp: _resp, reply, ref }) => ({ reply, ref })); + } + extractMeta(el, meta, value) { + const prefix = this.binding("value-"); + for (let i = 0; i < el.attributes.length; i++) { + if (!meta) { + meta = {}; + } + const name = el.attributes[i].name; + if (name.startsWith(prefix)) { + meta[name.replace(prefix, "")] = el.getAttribute(name); + } + } + if (el.value !== void 0 && !(el instanceof HTMLFormElement)) { + if (!meta) { + meta = {}; + } + meta.value = el.value; + if (el.tagName === "INPUT" && CHECKABLE_INPUTS.indexOf(el.type) >= 0 && !el.checked) { + delete meta.value; + } + } + if (value) { + if (!meta) { + meta = {}; + } + for (const key in value) { + meta[key] = value[key]; + } + } + return meta; + } + pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) { + this.pushWithReply( + (maybePayload) => this.putRef([{ el, loading: true, lock: true }], phxEvent, type, { + ...opts, + payload: maybePayload?.payload + }), + "event", + { + type, + event: phxEvent, + value: this.extractMeta(el, meta, opts.value), + cid: this.targetComponentID(el, targetCtx, opts) + } + ).then(({ reply }) => onReply && onReply(reply)).catch((error) => logError("Failed to push event", error)); + } + pushFileProgress(fileEl, entryRef, progress, onReply = function() { + }) { + this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => { + view.pushWithReply(null, "progress", { + event: fileEl.getAttribute(view.binding(PHX_PROGRESS)), + ref: fileEl.getAttribute(PHX_UPLOAD_REF), + entry_ref: entryRef, + progress, + cid: view.targetComponentID(fileEl.form, targetCtx) + }).then(() => onReply()).catch((error) => logError("Failed to push file progress", error)); + }); + } + pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback) { + if (!inputEl.form) { + throw new Error("form events require the input to be inside a form"); + } + let uploads; + const cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts); + const refGenerator = (maybePayload) => { + return this.putRef( + [ + { el: inputEl, loading: true, lock: true }, + { el: inputEl.form, loading: true, lock: true } + ], + phxEvent, + "change", + { ...opts, payload: maybePayload?.payload } + ); + }; + let formData; + const meta = this.extractMeta(inputEl.form, {}, opts.value); + const serializeOpts = {}; + if (inputEl instanceof HTMLButtonElement) { + serializeOpts.submitter = inputEl; + } + if (inputEl.getAttribute(this.binding("change"))) { + formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]); + } else { + formData = serializeForm(inputEl.form, serializeOpts); + } + if (dom_default.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0) { + LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)); + } + uploads = LiveUploader.serializeUploads(inputEl); + const event = { + type: "form", + event: phxEvent, + value: formData, + meta: { + // no target was implicitly sent as "undefined" in LV <= 1.0.5, therefore + // we have to keep it. In 1.0.6 we switched from passing meta as URL encoded data + // to passing it directly in the event, but the JSON encode would drop keys with + // undefined values. + _target: opts._target || "undefined", + ...meta + }, + uploads, + cid + }; + this.pushWithReply(refGenerator, "event", event).then(({ resp }) => { + if (dom_default.isUploadInput(inputEl) && dom_default.isAutoUpload(inputEl)) { + ElementRef.onUnlock(inputEl, () => { + if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) { + const [ref, _els] = refGenerator(); + this.undoRefs(ref, phxEvent, [inputEl.form]); + this.uploadFiles( + inputEl.form, + phxEvent, + targetCtx, + ref, + cid, + (_uploads) => { + callback && callback(resp); + this.triggerAwaitingSubmit(inputEl.form, phxEvent); + this.undoRefs(ref, phxEvent); + } + ); + } + }); + } else { + callback && callback(resp); + } + }).catch((error) => logError("Failed to push input event", error)); + } + triggerAwaitingSubmit(formEl, phxEvent) { + const awaitingSubmit = this.getScheduledSubmit(formEl); + if (awaitingSubmit) { + const [_el, _ref, _opts, callback] = awaitingSubmit; + this.cancelSubmit(formEl, phxEvent); + callback(); + } + } + getScheduledSubmit(formEl) { + return this.formSubmits.find( + ([el, _ref, _opts, _callback]) => el.isSameNode(formEl) + ); + } + scheduleSubmit(formEl, ref, opts, callback) { + if (this.getScheduledSubmit(formEl)) { + return true; + } + this.formSubmits.push([formEl, ref, opts, callback]); + } + cancelSubmit(formEl, phxEvent) { + this.formSubmits = this.formSubmits.filter( + ([el, ref, _opts, _callback]) => { + if (el.isSameNode(formEl)) { + this.undoRefs(ref, phxEvent); + return false; + } else { + return true; + } + } + ); + } + disableForm(formEl, phxEvent, opts = {}) { + const filterIgnored = (el) => { + const userIgnored = closestPhxBinding( + el, + `${this.binding(PHX_UPDATE)}=ignore`, + el.form + ); + return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)); + }; + const filterDisables = (el) => { + return el.hasAttribute(this.binding(PHX_DISABLE_WITH)); + }; + const filterButton = (el) => el.tagName == "BUTTON"; + const filterInput = (el) => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName); + const formElements = Array.from(formEl.elements); + const disables = formElements.filter(filterDisables); + const buttons = formElements.filter(filterButton).filter(filterIgnored); + const inputs = formElements.filter(filterInput).filter(filterIgnored); + buttons.forEach((button) => { + button.setAttribute(PHX_DISABLED, button.disabled); + button.disabled = true; + }); + inputs.forEach((input) => { + input.setAttribute(PHX_READONLY, input.readOnly); + input.readOnly = true; + if (input.files) { + input.setAttribute(PHX_DISABLED, input.disabled); + input.disabled = true; + } + }); + const formEls = disables.concat(buttons).concat(inputs).map((el) => { + return { el, loading: true, lock: true }; + }); + const els = [{ el: formEl, loading: true, lock: false }].concat(formEls).reverse(); + return this.putRef(els, phxEvent, "submit", opts); + } + pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) { + const refGenerator = (maybePayload) => this.disableForm(formEl, phxEvent, { + ...opts, + form: formEl, + payload: maybePayload?.payload, + submitter + }); + dom_default.putPrivate(formEl, "submitter", submitter); + const cid = this.targetComponentID(formEl, targetCtx); + if (LiveUploader.hasUploadsInProgress(formEl)) { + const [ref, _els] = refGenerator(); + const push = () => this.pushFormSubmit( + formEl, + targetCtx, + phxEvent, + submitter, + opts, + onReply + ); + return this.scheduleSubmit(formEl, ref, opts, push); + } else if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + const [ref, els] = refGenerator(); + const proxyRefGen = () => [ref, els, opts]; + this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (_uploads) => { + if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + return this.undoRefs(ref, phxEvent); + } + const meta = this.extractMeta(formEl, {}, opts.value); + const formData = serializeForm(formEl, { submitter }); + this.pushWithReply(proxyRefGen, "event", { + type: "form", + event: phxEvent, + value: formData, + meta, + cid + }).then(({ resp }) => onReply(resp)).catch((error) => logError("Failed to push form submit", error)); + }); + } else if (!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))) { + const meta = this.extractMeta(formEl, {}, opts.value); + const formData = serializeForm(formEl, { submitter }); + this.pushWithReply(refGenerator, "event", { + type: "form", + event: phxEvent, + value: formData, + meta, + cid + }).then(({ resp }) => onReply(resp)).catch((error) => logError("Failed to push form submit", error)); + } + } + uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete) { + const joinCountAtUpload = this.joinCount; + const inputEls = LiveUploader.activeFileInputs(formEl); + let numFileInputsInProgress = inputEls.length; + inputEls.forEach((inputEl) => { + const uploader = new LiveUploader(inputEl, this, () => { + numFileInputsInProgress--; + if (numFileInputsInProgress === 0) { + onComplete(); + } + }); + const entries = uploader.entries().map((entry) => entry.toPreflightPayload()); + if (entries.length === 0) { + numFileInputsInProgress--; + return; + } + const payload = { + ref: inputEl.getAttribute(PHX_UPLOAD_REF), + entries, + cid: this.targetComponentID(inputEl.form, targetCtx) + }; + this.log("upload", () => ["sending preflight request", payload]); + this.pushWithReply(null, "allow_upload", payload).then(({ resp }) => { + this.log("upload", () => ["got preflight response", resp]); + uploader.entries().forEach((entry) => { + if (resp.entries && !resp.entries[entry.ref]) { + this.handleFailedEntryPreflight( + entry.ref, + "failed preflight", + uploader + ); + } + }); + if (resp.error || Object.keys(resp.entries).length === 0) { + this.undoRefs(ref, phxEvent); + const errors = resp.error || []; + errors.map(([entry_ref, reason]) => { + this.handleFailedEntryPreflight(entry_ref, reason, uploader); + }); + } else { + const onError = (callback) => { + this.channel.onError(() => { + if (this.joinCount === joinCountAtUpload) { + callback(); + } + }); + }; + uploader.initAdapterUpload(resp, onError, this.liveSocket); + } + }).catch((error) => logError("Failed to push upload", error)); + }); + } + handleFailedEntryPreflight(uploadRef, reason, uploader) { + if (uploader.isAutoUpload()) { + const entry = uploader.entries().find((entry2) => entry2.ref === uploadRef.toString()); + if (entry) { + entry.cancel(); + } + } else { + uploader.entries().map((entry) => entry.cancel()); + } + this.log("upload", () => [`error for entry ${uploadRef}`, reason]); + } + dispatchUploads(targetCtx, name, filesOrBlobs) { + const targetElement = this.targetCtxElement(targetCtx) || this.el; + const inputs = dom_default.findUploadInputs(targetElement).filter( + (el) => el.name === name + ); + if (inputs.length === 0) { + logError(`no live file inputs found matching the name "${name}"`); + } else if (inputs.length > 1) { + logError(`duplicate live file inputs found matching the name "${name}"`); + } else { + dom_default.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, { + detail: { files: filesOrBlobs } + }); + } + } + targetCtxElement(targetCtx) { + if (isCid(targetCtx)) { + const [target] = dom_default.findComponentNodeList(this.id, targetCtx); + return target; + } else if (targetCtx) { + return targetCtx; + } else { + return null; + } + } + pushFormRecovery(oldForm, newForm, templateDom, callback) { + const phxChange = this.binding("change"); + const phxTarget = newForm.getAttribute(this.binding("target")) || newForm; + const phxEvent = newForm.getAttribute(this.binding(PHX_AUTO_RECOVER)) || newForm.getAttribute(this.binding("change")); + const inputs = Array.from(oldForm.elements).filter( + (el) => dom_default.isFormInput(el) && el.name && !el.hasAttribute(phxChange) + ); + if (inputs.length === 0) { + callback(); + return; + } + inputs.forEach( + (input2) => input2.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input2) + ); + const input = inputs.find((el) => el.type !== "hidden") || inputs[0]; + let pending = 0; + this.withinTargets( + phxTarget, + (targetView, targetCtx) => { + const cid = this.targetComponentID(newForm, targetCtx); + pending++; + let e = new CustomEvent("phx:form-recovery", { + detail: { sourceElement: oldForm } + }); + js_default.exec(e, "change", phxEvent, this, input, [ + "push", + { + _target: input.name, + targetView, + targetCtx, + newCid: cid, + callback: () => { + pending--; + if (pending === 0) { + callback(); + } + } + } + ]); + }, + templateDom + ); + } + pushLinkPatch(e, href, targetEl, callback) { + const linkRef = this.liveSocket.setPendingLink(href); + const loading = e.isTrusted && e.type !== "popstate"; + const refGen = targetEl ? () => this.putRef( + [{ el: targetEl, loading, lock: true }], + null, + "click" + ) : null; + const fallback = () => this.liveSocket.redirect(window.location.href); + const url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href; + this.pushWithReply(refGen, "live_patch", { url }).then( + ({ resp }) => { + this.liveSocket.requestDOMUpdate(() => { + if (resp.link_redirect) { + this.liveSocket.replaceMain(href, null, callback, linkRef); + } else if (resp.redirect) { + return; + } else { + if (this.liveSocket.commitPendingLink(linkRef)) { + this.href = href; + } + this.applyPendingUpdates(); + callback && callback(linkRef); + } + }); + }, + ({ error: _error, timeout: _timeout }) => fallback() + ); + } + getFormsForRecovery() { + if (this.joinCount === 0) { + return {}; + } + const phxChange = this.binding("change"); + return dom_default.all( + document, + `#${CSS.escape(this.id)} form[${phxChange}], [${PHX_TELEPORTED_REF}="${CSS.escape(this.id)}"] form[${phxChange}]` + ).filter((form) => form.id).filter((form) => form.elements.length > 0).filter( + (form) => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore" + ).map((form) => { + const clonedForm = form.cloneNode(true); + morphdom_esm_default(clonedForm, form, { + onBeforeElUpdated: (fromEl, toEl) => { + dom_default.copyPrivates(fromEl, toEl); + if (fromEl.getAttribute("form") === form.id) { + fromEl.parentNode.removeChild(fromEl); + return false; + } + return true; + } + }); + const externalElements = document.querySelectorAll( + `[form="${CSS.escape(form.id)}"]` + ); + Array.from(externalElements).forEach((el) => { + const clonedEl = ( + /** @type {HTMLElement} */ + el.cloneNode(true) + ); + morphdom_esm_default(clonedEl, el); + dom_default.copyPrivates(clonedEl, el); + clonedEl.removeAttribute("form"); + clonedForm.appendChild(clonedEl); + }); + return clonedForm; + }).reduce((acc, form) => { + acc[form.id] = form; + return acc; + }, {}); + } + maybePushComponentsDestroyed(destroyedCIDs) { + let willDestroyCIDs = destroyedCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.id, cid).length === 0; + }); + const onError = (error) => { + if (!this.isDestroyed()) { + logError("Failed to push components destroyed", error); + } + }; + if (willDestroyCIDs.length > 0) { + willDestroyCIDs.forEach((cid) => this.rendered.resetRender(cid)); + this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs }).then(() => { + this.liveSocket.requestDOMUpdate(() => { + let completelyDestroyCIDs = willDestroyCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.id, cid).length === 0; + }); + if (completelyDestroyCIDs.length > 0) { + this.pushWithReply(null, "cids_destroyed", { + cids: completelyDestroyCIDs + }).then(({ resp }) => { + this.rendered.pruneCIDs(resp.cids); + }).catch(onError); + } + }); + }).catch(onError); + } + } + ownsElement(el) { + let parentViewEl = dom_default.closestViewEl(el); + return el.getAttribute(PHX_PARENT_ID) === this.id || parentViewEl && parentViewEl.id === this.id || !parentViewEl && this.isDead; + } + submitForm(form, targetCtx, phxEvent, submitter, opts = {}) { + dom_default.putPrivate(form, PHX_HAS_SUBMITTED, true); + const inputs = Array.from(form.elements); + inputs.forEach((input) => dom_default.putPrivate(input, PHX_HAS_SUBMITTED, true)); + this.liveSocket.blurActiveElement(this); + this.pushFormSubmit(form, targetCtx, phxEvent, submitter, opts, () => { + this.liveSocket.restorePreviouslyActiveFocus(); + }); + } + binding(kind) { + return this.liveSocket.binding(kind); + } + // phx-portal + pushPortalElementId(id) { + this.portalElementIds.add(id); + } + dropPortalElementId(id) { + this.portalElementIds.delete(id); + } + destroyPortalElements() { + if (!this.liveSocket.unloaded) { + this.portalElementIds.forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.remove(); + } + }); + } + } + }; + var LiveSocket = class { + constructor(url, phxSocket, opts = {}) { + this.unloaded = false; + if (!phxSocket || phxSocket.constructor.name === "Object") { + throw new Error(` + a phoenix Socket must be provided as the second argument to the LiveSocket constructor. For example: + + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + let liveSocket = new LiveSocket("/live", Socket, {...}) + `); + } + this.socket = new phxSocket(url, opts); + this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX; + this.opts = opts; + this.params = closure2(opts.params || {}); + this.viewLogger = opts.viewLogger; + this.metadataCallbacks = opts.metadata || {}; + this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {}); + this.prevActive = null; + this.silenced = false; + this.main = null; + this.outgoingMainEl = null; + this.clickStartedAtTarget = null; + this.linkRef = 1; + this.roots = {}; + this.href = window.location.href; + this.pendingLink = null; + this.currentLocation = clone(window.location); + this.hooks = opts.hooks || {}; + this.uploaders = opts.uploaders || {}; + this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT; + this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT; + this.reloadWithJitterTimer = null; + this.maxReloads = opts.maxReloads || MAX_RELOADS; + this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN; + this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX; + this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER; + this.localStorage = opts.localStorage || window.localStorage; + this.sessionStorage = opts.sessionStorage || window.sessionStorage; + this.boundTopLevelEvents = false; + this.boundEventNames = /* @__PURE__ */ new Set(); + this.blockPhxChangeWhileComposing = opts.blockPhxChangeWhileComposing || false; + this.serverCloseRef = null; + this.domCallbacks = Object.assign( + { + jsQuerySelectorAll: null, + onPatchStart: closure2(), + onPatchEnd: closure2(), + onNodeAdded: closure2(), + onBeforeElUpdated: closure2() + }, + opts.dom || {} + ); + this.transitions = new TransitionSet(); + this.currentHistoryPosition = parseInt(this.sessionStorage.getItem(PHX_LV_HISTORY_POSITION)) || 0; + window.addEventListener("pagehide", (_e) => { + this.unloaded = true; + }); + this.socket.onOpen(() => { + if (this.isUnloaded()) { + window.location.reload(); + } + }); + } + // public + version() { + return "1.1.30"; + } + isProfileEnabled() { + return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true"; + } + isDebugEnabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true"; + } + isDebugDisabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false"; + } + enableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "true"); + } + enableProfiling() { + this.sessionStorage.setItem(PHX_LV_PROFILE, "true"); + } + disableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "false"); + } + disableProfiling() { + this.sessionStorage.removeItem(PHX_LV_PROFILE); + } + enableLatencySim(upperBoundMs) { + this.enableDebug(); + console.log( + "latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable" + ); + this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs); + } + disableLatencySim() { + this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM); + } + getLatencySim() { + const str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM); + return str ? parseInt(str) : null; + } + getSocket() { + return this.socket; + } + connect() { + if (window.location.hostname === "localhost" && !this.isDebugDisabled()) { + this.enableDebug(); + } + const doConnect = () => { + this.resetReloadStatus(); + if (this.joinRootViews()) { + this.bindTopLevelEvents(); + this.socket.connect(); + } else if (this.main) { + this.socket.connect(); + } else { + this.bindTopLevelEvents({ dead: true }); + } + this.joinDeadView(); + }; + if (["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0) { + doConnect(); + } else { + document.addEventListener("DOMContentLoaded", () => doConnect()); + } + } + disconnect(callback) { + clearTimeout(this.reloadWithJitterTimer); + if (this.serverCloseRef) { + this.socket.off(this.serverCloseRef); + this.serverCloseRef = null; + } + this.socket.disconnect(callback); + } + replaceTransport(transport) { + clearTimeout(this.reloadWithJitterTimer); + this.socket.replaceTransport(transport); + this.connect(); + } + /** + * @param {HTMLElement} el + * @param {string} encodedJS + * @param {string | null} [eventType] + */ + execJS(el, encodedJS, eventType = null) { + const e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); + this.owner(el, (view) => js_default.exec(e, eventType, encodedJS, view, el)); + } + /** + * Returns an object with methods to manipulate the DOM and execute JavaScript. + * The applied changes integrate with server DOM patching. + * + * @returns {import("./js_commands").LiveSocketJSCommands} + */ + js() { + return js_commands_default(this, "js"); + } + // private + unload() { + if (this.unloaded) { + return; + } + if (this.main && this.isConnected()) { + this.log(this.main, "socket", () => ["disconnect for page nav"]); + } + this.unloaded = true; + this.destroyAllViews(); + this.disconnect(); + } + triggerDOM(kind, args) { + this.domCallbacks[kind](...args); + } + time(name, func) { + if (!this.isProfileEnabled() || !console.time) { + return func(); + } + console.time(name); + const result = func(); + console.timeEnd(name); + return result; + } + log(view, kind, msgCallback) { + if (this.viewLogger) { + const [msg, obj] = msgCallback(); + this.viewLogger(view, kind, msg, obj); + } else if (this.isDebugEnabled()) { + const [msg, obj] = msgCallback(); + debug(view, kind, msg, obj); + } + } + requestDOMUpdate(callback) { + this.transitions.after(callback); + } + asyncTransition(promise) { + this.transitions.addAsyncTransition(promise); + } + transition(time, onStart, onDone = function() { + }) { + this.transitions.addTransition(time, onStart, onDone); + } + onChannel(channel, event, cb) { + channel.on(event, (data) => { + const latency = this.getLatencySim(); + if (!latency) { + cb(data); + } else { + setTimeout(() => cb(data), latency); + } + }); + } + reloadWithJitter(view, log) { + clearTimeout(this.reloadWithJitterTimer); + this.disconnect(); + const minMs = this.reloadJitterMin; + const maxMs = this.reloadJitterMax; + let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; + const tries = browser_default.updateLocal( + this.localStorage, + window.location.pathname, + CONSECUTIVE_RELOADS, + 0, + (count) => count + 1 + ); + if (tries >= this.maxReloads) { + afterMs = this.failsafeJitter; + } + this.reloadWithJitterTimer = setTimeout(() => { + if (view.isDestroyed() || view.isConnected()) { + return; + } + view.destroy(); + log ? log() : this.log(view, "join", () => [ + `encountered ${tries} consecutive reloads` + ]); + if (tries >= this.maxReloads) { + this.log(view, "join", () => [ + `exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode` + ]); + } + if (this.hasPendingLink()) { + window.location = this.pendingLink; + } else { + window.location.reload(); + } + }, afterMs); + } + getHookDefinition(name) { + if (!name) { + return; + } + return this.maybeInternalHook(name) || this.hooks[name] || this.maybeRuntimeHook(name); + } + maybeInternalHook(name) { + return name && name.startsWith("Phoenix.") && hooks_default[name.split(".")[1]]; + } + maybeRuntimeHook(name) { + const runtimeHook = document.querySelector( + `script[${PHX_RUNTIME_HOOK}="${CSS.escape(name)}"]` + ); + if (!runtimeHook) { + return; + } + let callbacks = window[`phx_hook_${name}`]; + if (!callbacks || typeof callbacks !== "function") { + logError("a runtime hook must be a function", runtimeHook); + return; + } + const hookDefiniton = callbacks(); + if (hookDefiniton && (typeof hookDefiniton === "object" || typeof hookDefiniton === "function")) { + return hookDefiniton; + } + logError( + "runtime hook must return an object with hook callbacks or an instance of ViewHook", + runtimeHook + ); + } + isUnloaded() { + return this.unloaded; + } + isConnected() { + return this.socket.isConnected(); + } + getBindingPrefix() { + return this.bindingPrefix; + } + binding(kind) { + return `${this.getBindingPrefix()}${kind}`; + } + channel(topic, params) { + return this.socket.channel(topic, params); + } + joinDeadView() { + const body = document.body; + if (body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)) { + const view = this.newRootView(body); + view.setHref(this.getHref()); + view.joinDead(); + if (!this.main) { + this.main = view; + } + window.requestAnimationFrame(() => { + view.execNewMounted(); + this.maybeScroll(history.state?.scroll); + }); + } + } + joinRootViews() { + let rootsFound = false; + dom_default.all( + document, + `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, + (rootEl) => { + if (!this.getRootById(rootEl.id)) { + const view = this.newRootView(rootEl); + if (!dom_default.isPhxSticky(rootEl)) { + view.setHref(this.getHref()); + } + view.join(); + if (rootEl.hasAttribute(PHX_MAIN)) { + this.main = view; + } + } + rootsFound = true; + } + ); + return rootsFound; + } + redirect(to, flash, reloadToken) { + if (reloadToken) { + browser_default.setCookie(PHX_RELOAD_STATUS, reloadToken, 60); + } + this.unload(); + browser_default.redirect(to, flash); + } + replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)) { + const liveReferer = this.currentLocation.href; + this.outgoingMainEl = this.outgoingMainEl || this.main.el; + const stickies = dom_default.findPhxSticky(document) || []; + const removeEls = dom_default.all( + this.outgoingMainEl, + `[${this.binding("remove")}]` + ).filter((el) => !dom_default.isChildOfAny(el, stickies)); + const newMainEl = dom_default.cloneNode(this.outgoingMainEl, ""); + this.main.showLoader(this.loaderTimeout); + this.main.destroy(); + this.main = this.newRootView(newMainEl, flash, liveReferer); + this.main.setRedirect(href); + this.transitionRemoves(removeEls); + this.main.join((joinCount, onDone) => { + if (joinCount === 1 && this.commitPendingLink(linkRef)) { + this.requestDOMUpdate(() => { + removeEls.forEach((el) => el.remove()); + stickies.forEach((el) => newMainEl.appendChild(el)); + this.outgoingMainEl.replaceWith(newMainEl); + this.outgoingMainEl = null; + callback && callback(linkRef); + onDone(); + }); + } + }); + } + transitionRemoves(elements, callback) { + const removeAttr = this.binding("remove"); + const silenceEvents = (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + elements.forEach((el) => { + for (const event of this.boundEventNames) { + el.addEventListener(event, silenceEvents, true); + } + this.execJS(el, el.getAttribute(removeAttr), "remove"); + }); + this.requestDOMUpdate(() => { + elements.forEach((el) => { + for (const event of this.boundEventNames) { + el.removeEventListener(event, silenceEvents, true); + } + }); + callback && callback(); + }); + } + isPhxView(el) { + return el.getAttribute && el.getAttribute(PHX_SESSION) !== null; + } + newRootView(el, flash, liveReferer) { + const view = new View(el, this, null, flash, liveReferer); + this.roots[view.id] = view; + return view; + } + owner(childEl, callback) { + let view; + const viewEl = dom_default.closestViewEl(childEl); + if (viewEl) { + view = this.getViewByEl(viewEl); + } else { + if (!childEl.isConnected) { + return null; + } + view = this.main; + } + return view && callback ? callback(view) : view; + } + withinOwners(childEl, callback) { + this.owner(childEl, (view) => callback(view, childEl)); + } + getViewByEl(el) { + const rootId = el.getAttribute(PHX_ROOT_ID); + return maybe( + this.getRootById(rootId), + (root) => root.getDescendentByEl(el) + ); + } + getRootById(id) { + return this.roots[id]; + } + destroyAllViews() { + for (const id in this.roots) { + this.roots[id].destroy(); + delete this.roots[id]; + } + this.main = null; + } + destroyViewByEl(el) { + const root = this.getRootById(el.getAttribute(PHX_ROOT_ID)); + if (root && root.id === el.id) { + root.destroy(); + delete this.roots[root.id]; + } else if (root) { + root.destroyDescendent(el.id); + } + } + getActiveElement() { + return document.activeElement; + } + dropActiveElement(view) { + if (this.prevActive && view.ownsElement(this.prevActive)) { + this.prevActive = null; + } + } + restorePreviouslyActiveFocus() { + if (this.prevActive && this.prevActive !== document.body && this.prevActive instanceof HTMLElement) { + this.prevActive.focus(); + } + } + blurActiveElement() { + this.prevActive = this.getActiveElement(); + if (this.prevActive !== document.body && this.prevActive instanceof HTMLElement) { + this.prevActive.blur(); + } + } + /** + * @param {{dead?: boolean}} [options={}] + */ + bindTopLevelEvents({ dead } = {}) { + if (this.boundTopLevelEvents) { + return; + } + this.boundTopLevelEvents = true; + this.serverCloseRef = this.socket.onClose((event) => { + if (event && event.code === 1e3 && this.main) { + return this.reloadWithJitter(this.main); + } + }); + document.body.addEventListener("click", function() { + }); + window.addEventListener( + "pageshow", + (e) => { + if (e.persisted) { + this.getSocket().disconnect(); + this.withPageLoading({ to: window.location.href, kind: "redirect" }); + window.location.reload(); + } + }, + true + ); + if (!dead) { + this.bindNav(); + } + this.bindClicks(); + if (!dead) { + this.bindForms(); + } + this.bind( + { keyup: "keyup", keydown: "keydown" }, + (e, type, view, targetEl, phxEvent, _phxTarget) => { + const matchKey = targetEl.getAttribute(this.binding(PHX_KEY)); + const pressedKey = e.key && e.key.toLowerCase(); + if (matchKey && matchKey.toLowerCase() !== pressedKey) { + return; + } + const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + ); + this.bind( + { blur: "focusout", focus: "focusin" }, + (e, type, view, targetEl, phxEvent, phxTarget) => { + if (!phxTarget) { + const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + } + ); + this.bind( + { blur: "blur", focus: "focus" }, + (e, type, view, targetEl, phxEvent, phxTarget) => { + if (phxTarget === "window") { + const data = this.eventMeta(type, e, targetEl); + js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + } + ); + this.on("dragover", (e) => e.preventDefault()); + this.on("dragenter", (e) => { + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET) + ); + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + if (eventContainsFiles(e)) { + this.js().addClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + } + }); + this.on("dragleave", (e) => { + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET) + ); + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + const rect = dropzone.getBoundingClientRect(); + if (e.clientX <= rect.left || e.clientX >= rect.right || e.clientY <= rect.top || e.clientY >= rect.bottom) { + this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + } + }); + this.on("drop", (e) => { + e.preventDefault(); + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET) + ); + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + const dropTargetId = dropzone.getAttribute(this.binding(PHX_DROP_TARGET)); + const dropTarget = dropTargetId && document.getElementById(dropTargetId); + const files = Array.from(e.dataTransfer.files || []); + if (!dropTarget || !(dropTarget instanceof HTMLInputElement) || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)) { + return; + } + LiveUploader.trackFiles(dropTarget, files, e.dataTransfer); + dropTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + this.on(PHX_TRACK_UPLOADS, (e) => { + const uploadTarget = e.target; + if (!dom_default.isUploadInput(uploadTarget)) { + return; + } + const files = Array.from(e.detail.files || []).filter( + (f) => f instanceof File || f instanceof Blob + ); + LiveUploader.trackFiles(uploadTarget, files); + uploadTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + eventMeta(eventName, e, targetEl) { + const callback = this.metadataCallbacks[eventName]; + return callback ? callback(e, targetEl) : {}; + } + setPendingLink(href) { + this.linkRef++; + this.pendingLink = href; + this.resetReloadStatus(); + return this.linkRef; + } + // anytime we are navigating or connecting, drop reload cookie in case + // we issue the cookie but the next request was interrupted and the server never dropped it + resetReloadStatus() { + browser_default.deleteCookie(PHX_RELOAD_STATUS); + } + commitPendingLink(linkRef) { + if (this.linkRef !== linkRef) { + return false; + } else { + this.href = this.pendingLink; + this.pendingLink = null; + return true; + } + } + getHref() { + return this.href; + } + hasPendingLink() { + return !!this.pendingLink; + } + bind(events, callback) { + for (const event in events) { + const browserEventName = events[event]; + this.on(browserEventName, (e) => { + const binding = this.binding(event); + const windowBinding = this.binding(`window-${event}`); + const targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding); + if (targetPhxEvent) { + this.debounce(e.target, e, browserEventName, () => { + this.withinOwners(e.target, (view) => { + callback(e, event, view, e.target, targetPhxEvent, null); + }); + }); + } else { + dom_default.all(document, `[${windowBinding}]`, (el) => { + const phxEvent = el.getAttribute(windowBinding); + this.debounce(el, e, browserEventName, () => { + this.withinOwners(el, (view) => { + callback(e, event, view, el, phxEvent, "window"); + }); + }); + }); + } + }); + } + } + bindClicks() { + this.on("mousedown", (e) => this.clickStartedAtTarget = e.target); + this.bindClick("click", "click"); + } + bindClick(eventName, bindingName) { + const click = this.binding(bindingName); + window.addEventListener( + eventName, + (e) => { + let target = null; + if (e.detail === 0) + this.clickStartedAtTarget = e.target; + const clickStartedAtTarget = this.clickStartedAtTarget || e.target; + target = closestPhxBinding(e.target, click); + this.dispatchClickAway(e, clickStartedAtTarget); + this.clickStartedAtTarget = null; + const phxEvent = target && target.getAttribute(click); + if (!phxEvent) { + if (dom_default.isNewPageClick(e, window.location)) { + this.unload(); + } + return; + } + if (target.getAttribute("href") === "#") { + e.preventDefault(); + } + if (target.hasAttribute(PHX_REF_SRC)) { + return; + } + this.debounce(target, e, "click", () => { + this.withinOwners(target, (view) => { + js_default.exec(e, "click", phxEvent, view, target, [ + "push", + { data: this.eventMeta("click", e, target) } + ]); + }); + }); + }, + false + ); + } + dispatchClickAway(e, clickStartedAt) { + const phxClickAway = this.binding("click-away"); + const portal = clickStartedAt.closest(`[${PHX_TELEPORTED_SRC}]`); + const portalStartedAt = portal && dom_default.byId(portal.getAttribute(PHX_TELEPORTED_SRC)); + dom_default.all(document, `[${phxClickAway}]`, (el) => { + let startedAt = clickStartedAt; + if (portal && !portal.contains(el)) { + startedAt = portalStartedAt; + } + if (!(el.isSameNode(startedAt) || el.contains(startedAt) || // When clicking a link with custom method, + // phoenix_html triggers a click on a submit button + // of a hidden form appended to the body. For such cases + // where the clicked target is hidden, we skip click-away. + // + // Also, when we have a portal, we don't want to check the visibility + // of the portal source, as it's a