defmodule PhoenixKit.Modules.Sync.Web.Sender do @moduledoc """ Sender-side LiveView for DB Sync. This is the site that has data to share with another site. ## Flow 1. Generate a connection code 2. Share the code with the receiver (along with this site's URL) 3. Wait for receiver to connect 4. Serve data requests from the receiver """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKit.Modules.Sync alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes require Logger @impl true def mount(params, _session, socket) do locale = params["locale"] || "en" project_title = Settings.get_project_title() site_url = Settings.get_setting("site_url", "") socket = socket |> assign(:page_title, "Send Data") |> assign(:project_title, project_title) |> assign(:current_locale, locale) |> assign(:current_path, Routes.path("/admin/sync/send", locale: locale)) |> assign(:site_url, site_url) |> assign(:step, :generate_code) |> assign(:session, nil) # Multiple receivers support - map of pid => receiver_data |> assign(:receivers, %{}) |> assign(:connection_status, nil) {:ok, socket} end @impl true def handle_params(_params, _url, socket) do {:noreply, socket} end @impl true def terminate(_reason, socket) do # Clean up session on unmount if socket.assigns.session do Sync.delete_session(socket.assigns.session.code) end :ok end # =========================================== # EVENT HANDLERS # =========================================== @impl true def handle_event("generate_code", _params, socket) do case Sync.create_session(:send) do {:ok, session} -> socket = socket |> assign(:session, session) |> assign(:step, :waiting_for_receiver) {:noreply, socket} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to generate code: #{inspect(reason)}")} end end @impl true def handle_event("regenerate_code", _params, socket) do if socket.assigns.session do Sync.delete_session(socket.assigns.session.code) end case Sync.create_session(:send) do {:ok, session} -> socket = socket |> assign(:session, session) |> assign(:receivers, %{}) {:noreply, socket} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to generate code: #{inspect(reason)}")} end end @impl true def handle_event("cancel", _params, socket) do if socket.assigns.session do Sync.delete_session(socket.assigns.session.code) end socket = socket |> assign(:session, nil) |> assign(:step, :generate_code) |> assign(:receivers, %{}) {:noreply, socket} end @impl true def handle_event("disconnect", _params, socket) do if socket.assigns.session do Sync.delete_session(socket.assigns.session.code) end socket = socket |> assign(:session, nil) |> assign(:step, :generate_code) |> assign(:receivers, %{}) |> assign(:connection_status, nil) {:noreply, socket} end @impl true def handle_event("disconnect_receiver", %{"pid" => pid_string}, socket) do # Find and remove the receiver by PID string pid = string_to_pid(pid_string) if pid && Map.has_key?(socket.assigns.receivers, pid) do # The channel will terminate when process exits, but we can also # remove it from our tracking immediately receivers = Map.delete(socket.assigns.receivers, pid) socket = socket |> assign(:receivers, receivers) |> maybe_update_step_for_receivers() {:noreply, put_flash(socket, :info, "Receiver disconnected")} else {:noreply, socket} end end # =========================================== # MESSAGE HANDLERS (from Channel) # =========================================== @impl true def handle_info({:sync, {:receiver_joined, channel_pid, full_info}}, socket) do Logger.info("Sync.Sender: Receiver connected - #{inspect(full_info)}") # Extract receiver_info and connection_info from the full_info map # Handle both new format (with :receiver_info/:connection_info keys) and old format {receiver_info, connection_info} = case full_info do %{receiver_info: ri, connection_info: ci} -> {ri, ci} info when is_map(info) -> {info, %{}} end # Add this receiver to our map receiver_data = %{ receiver_info: receiver_info, connection_info: connection_info, connected_at: DateTime.utc_now() } receivers = Map.put(socket.assigns.receivers, channel_pid, receiver_data) socket = socket |> assign(:receivers, receivers) |> assign(:step, :connected) |> assign(:connection_status, nil) {:noreply, socket} end # Handle old message format (backwards compatibility) @impl true def handle_info({:sync, {:receiver_joined, channel_pid}}, socket) do Logger.info("Sync.Sender: Receiver connected (no info)") receiver_data = %{ receiver_info: %{}, connection_info: %{}, connected_at: DateTime.utc_now() } receivers = Map.put(socket.assigns.receivers, channel_pid, receiver_data) socket = socket |> assign(:receivers, receivers) |> assign(:step, :connected) |> assign(:connection_status, nil) {:noreply, socket} end @impl true def handle_info({:sync, {:receiver_disconnected, channel_pid}}, socket) do Logger.info("Sync.Sender: Receiver disconnected - #{inspect(channel_pid)}") receivers = Map.delete(socket.assigns.receivers, channel_pid) socket = socket |> assign(:receivers, receivers) |> maybe_update_step_for_receivers() |> put_flash(:info, "A receiver disconnected") {:noreply, socket} end # Handle old message format (backwards compatibility) - removes all receivers @impl true def handle_info({:sync, :receiver_disconnected}, socket) do Logger.info("Sync.Sender: Receiver disconnected (old format)") # For old format, we don't know which receiver, so just flash a message # The channel terminate will send the new format with PID socket = put_flash(socket, :info, "A receiver disconnected") {:noreply, socket} end @impl true def handle_info({:sync, msg}, socket) do Logger.debug("Sync.Sender: Received message - #{inspect(msg)}") {:noreply, socket} end @impl true def handle_info(_msg, socket) do {:noreply, socket} end # =========================================== # RENDER # =========================================== @impl true def render(assigns) do ~H"""
<%!-- Header Section --%>
<.link navigate={Routes.path("/admin/sync", locale: @current_locale)} class="btn btn-outline btn-primary btn-sm absolute left-0 top-0" > <.icon name="hero-arrow-left" class="w-4 h-4" /> Back

Send Data

Share your data with another site

<%= case @step do %> <% :generate_code -> %> <.render_generate_code_step {assigns} /> <% :waiting_for_receiver -> %> <.render_waiting_step {assigns} /> <% :connected -> %> <.render_connected_step {assigns} /> <% end %>
""" end defp render_generate_code_step(assigns) do ~H"""
📤

Ready to Send

Click the button below to generate a connection code. Share this code and your site URL with the site that wants to receive your data.

""" end defp render_waiting_step(assigns) do ~H"""

<.icon name="hero-signal" class="w-6 h-6 text-primary animate-pulse" /> Waiting for Connection

<%!-- Connection Code Display --%>

Connection Code

{@session.code}
<%!-- Site URL --%> <%= if @site_url != "" do %>

Your Site URL

{@site_url}

<% else %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> Site URL not configured. Set it in <.link navigate={Routes.path("/admin/settings")} class="link">Settings for easier sharing.
<% end %> <%!-- Instructions --%>

Share with the receiver:

  1. Your site URL (above)
  2. The connection code

The receiver will enter these on their "Receive Data" page to connect.

<%!-- Session Notice --%>
<.icon name="hero-signal" class="w-4 h-4 inline" /> Code is valid while this page stays open
<%!-- Actions --%>
""" end defp render_connected_step(assigns) do receiver_count = map_size(assigns.receivers) assigns = assign(assigns, :receiver_count, receiver_count) ~H"""
<%!-- Connection Status Header --%>
✅

<%= if @receiver_count == 1 do %> Receiver Connected! <% else %> {@receiver_count} Receivers Connected! <% end %>

<%= if @receiver_count == 1 do %> A receiver is connected and can browse your data. <% else %> Multiple receivers are connected and can browse your data. <% end %>

Session Code

{@session.code}

<%= if @connection_status do %>
<.icon name="hero-information-circle" class="w-5 h-5" /> {@connection_status}
<% end %>
<%!-- Connected Receivers --%> <%= for {pid, receiver_data} <- @receivers do %> <.render_receiver_card pid={pid} receiver_data={receiver_data} show_disconnect={@receiver_count > 0} /> <% end %> <%!-- Session Info --%>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />

Keep this page open!

Your data is being served to receivers while this page remains open. Closing this page will disconnect all transfer sessions.

""" end attr :pid, :any, required: true attr :receiver_data, :map, required: true attr :show_disconnect, :boolean, default: true defp render_receiver_card(assigns) do receiver_info = assigns.receiver_data.receiver_info || %{} connection_info = assigns.receiver_data.connection_info || %{} assigns = assigns |> assign(:receiver_info, receiver_info) |> assign(:connection_info, connection_info) ~H"""
<%!-- Header with receiver identity --%>

<.icon name="hero-user-circle" class="w-5 h-5 text-primary" /> <%= if @receiver_info["user_name"] || @receiver_info["user_email"] do %> {@receiver_info["user_name"] || @receiver_info["user_email"]} <% else %> Anonymous Receiver <% end %>

<%= if @receiver_info["project_title"] do %>

from {@receiver_info["project_title"]}

<% end %>
<%= if @show_disconnect do %> <% end %>
<%!-- Connection details grid --%>
<%= if @receiver_info["site_url"] do %> <.info_field label="Site URL" value={@receiver_info["site_url"]} icon="hero-globe-alt" mono /> <% end %> <%= if @connection_info[:remote_ip] do %> <.info_field label="Remote IP" value={@connection_info[:remote_ip]} icon="hero-map-pin" /> <% end %> <.info_field label="Connected" value={format_datetime(@receiver_data.connected_at)} icon="hero-clock" /> <%= if @connection_info[:origin] do %> <.info_field label="Origin" value={@connection_info[:origin]} icon="hero-arrow-top-right-on-square" mono /> <% end %>
<%!-- Expandable details --%> <%= if map_size(@connection_info) > 2 do %>
More connection details...
<%= if @connection_info[:host] do %> <.info_field label="Host" value={format_host(@connection_info)} icon="hero-server" mono /> <% end %> <%= if @connection_info[:referer] do %> <.info_field label="Referer" value={@connection_info[:referer]} icon="hero-link" mono /> <% end %> <%= if @connection_info[:websocket_version] do %> <.info_field label="WS Version" value={@connection_info[:websocket_version]} icon="hero-bolt" /> <% end %> <%= if @connection_info[:accept_language] do %> <.info_field label="Language" value={@connection_info[:accept_language]} icon="hero-language" /> <% end %>
<%= if @connection_info[:user_agent] do %>

User Agent

{@connection_info[:user_agent]}

<% end %>
<% end %>
""" end # Component for displaying an info field attr :label, :string, required: true attr :value, :any, default: nil attr :icon, :string, default: nil attr :mono, :boolean, default: false defp info_field(assigns) do ~H"""

<%= if @icon do %> <.icon name={@icon} class="w-3 h-3" /> <% end %> {@label}

<%= if @value do %>

{@value}

<% else %>

Not provided

<% end %>
""" end defp format_datetime(nil), do: nil defp format_datetime(%DateTime{} = dt) do Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC") end defp format_datetime(_), do: nil defp format_host(%{scheme: scheme, host: host, port: port}) when not is_nil(host) do "#{scheme}://#{host}:#{port}" end defp format_host(_), do: nil # Helper to update step when receivers change defp maybe_update_step_for_receivers(socket) do if map_size(socket.assigns.receivers) == 0 do assign(socket, :step, :waiting_for_receiver) else socket end end # Convert PID string back to PID (for disconnect button) defp string_to_pid(pid_string) when is_binary(pid_string) do # PID string looks like "#PID<0.123.0>" - extract the numbers case Regex.run(~r/<(.+)>/, pid_string) do [_, pid_part] -> :erlang.list_to_pid(~c"<#{pid_part}>") _ -> nil end rescue _ -> nil end defp string_to_pid(_), do: nil # Convert PID to string for use in HTML attributes defp pid_to_string(pid) when is_pid(pid), do: inspect(pid) defp pid_to_string(_), do: "" end