diff --git a/assets/js/dashboard/components/liveview-portal.tsx b/assets/js/dashboard/components/liveview-portal.tsx new file mode 100644 index 000000000000..5e3c54db8b69 --- /dev/null +++ b/assets/js/dashboard/components/liveview-portal.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +type LiveViewPortalProps = { + id: string + className?: string +} + +export const LiveViewPortal = React.memo( + function ({ id, className }: LiveViewPortalProps) { + return ( +
+ ) + }, + () => true +) diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 24f467a8427b..853d54166ed2 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -1,13 +1,15 @@ -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import VisitorGraph from './stats/graph/visitor-graph' import Sources from './stats/sources' -import Pages from './stats/pages' +import { LiveViewPortal } from './components/liveview-portal' import Locations from './stats/locations' import Devices from './stats/devices' import { TopBar } from './nav-menu/top-bar' import Behaviours from './stats/behaviours' import { useQueryContext } from './query-context' import { isRealTimeDashboard } from './util/filters' +import { useAppNavigate } from './navigation/use-app-navigate' +import { parseSearch } from './util/url-search-params' function DashboardStats({ importedDataInView, @@ -16,6 +18,21 @@ function DashboardStats({ importedDataInView?: boolean updateImportedDataInView?: (v: boolean) => void }) { + const navigate = useAppNavigate() + + useEffect(() => { + const unsubscribe = window.addEventListener('live-navigate', (( + e: CustomEvent + ) => { + navigate({ + path: e.detail.path, + search: () => parseSearch(e.detail.search), + replace: true + }) + }) as EventListener) + return unsubscribe + }, [navigate]) + const statsBoxClass = 'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0' @@ -27,7 +44,10 @@ function DashboardStats({
- +
diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx index fa2fe9257fe7..ddd194a6b5c2 100644 --- a/assets/js/dashboard/navigation/use-app-navigate.tsx +++ b/assets/js/dashboard/navigation/use-app-navigate.tsx @@ -63,6 +63,12 @@ export const useAppNavigate = () => { search, ...options }: AppNavigationTarget & NavigateOptions) => { + window.dispatchEvent( + new CustomEvent('live-navigate-back', { + detail: { search: window.location.search } + }) + ) + return _navigate(getToOptions({ path, params, search }), options) }, [getToOptions, _navigate] diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index eff35b3e73db..f89add62ae5f 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -212,7 +212,7 @@ export function createAppRouter(site: PlausibleSite) { utmTermsRoute, referrersGoogleRoute, referrersDrilldownRoute, - topPagesRoute, + // topPagesRoute, entryPagesRoute, exitPagesRoute, countriesRoute, diff --git a/assets/js/liveview/live_dashboard.js b/assets/js/liveview/live_dashboard.js new file mode 100644 index 000000000000..e484f3f24734 --- /dev/null +++ b/assets/js/liveview/live_dashboard.js @@ -0,0 +1,139 @@ +const WIDGETS = { + 'breakdown-tile': { + initialize: function () { + this.url = window.location.href + + this.listeners = [] + + const localStorageListener = (e) => { + localStorage.setItem(e.detail.key, e.detail.value) + } + + window.addEventListener('phx:update_local_storage', localStorageListener) + + this.listeners.push({ + element: window, + event: 'phx:update_local_storage', + callback: localStorageListener + }) + + const closeModalListener = ((e) => { + this.el.dispatchEvent( + new CustomEvent('live-navigate', { + bubbles: true, + detail: { path: '/', search: window.location.search } + }) + ) + }).bind(this) + + window.addEventListener('dashboard:close_modal', closeModalListener) + + this.listeners.push({ + element: window, + event: 'dashboard:close_modal', + callback: closeModalListener + }) + + const clickListener = ((e) => { + const type = e.target.dataset.type || null + + if (type && type == 'dashboard-link') { + this.url = e.target.href + const uri = new URL(this.url) + const path = '/' + uri.pathname.split('/').slice(2).join('/') + this.el.dispatchEvent( + new CustomEvent('live-navigate', { + bubbles: true, + detail: { path: path, search: uri.search } + }) + ) + + this.pushEvent('handle_dashboard_params', { url: this.url }) + + e.preventDefault() + } + }).bind(this) + + this.el.addEventListener('click', clickListener) + + this.listeners.push({ + element: this.el, + event: 'click', + callback: clickListener + }) + + const popListener = (() => { + if (this.url !== window.location.href) { + this.pushEvent('handle_dashboard_params', { + url: window.location.href + }) + } + }).bind(this) + + window.addEventListener('popstate', popListener) + + this.listeners.push({ + element: window, + event: 'popstate', + callback: popListener + }) + + const backListener = ((e) => { + if ( + typeof e.detail.search === 'string' && + this.url !== window.location.href + ) { + this.pushEvent('handle_dashboard_params', { + url: window.location.href + }) + } + }).bind(this) + + window.addEventListener('live-navigate-back', backListener) + + this.listeners.push({ + element: window, + event: 'live-navigate-back', + callback: backListener + }) + }, + cleanup: function () { + if (this.listeners) { + this.listeners.forEach((l) => { + l.element.removeEventListener(l.event, l.callback) + }) + + this.listeners = null + } + } + } +} + +export default { + mounted() { + this.widget = this.el.getAttribute('data-widget') + + this.initialize() + }, + + updated() { + this.initialize() + }, + + reconnected() { + this.initialize() + }, + + destroyed() { + this.cleanup() + }, + + initialize() { + this.cleanup() + WIDGETS[this.widget].initialize.bind(this)() + }, + + cleanup() { + WIDGETS[this.widget].cleanup.bind(this)() + } +} diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index c0f723f8cf29..b4aa3db361b2 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -7,6 +7,7 @@ import 'phoenix_html' import { Socket } from 'phoenix' import { LiveSocket } from 'phoenix_live_view' import { Modal, Dropdown } from 'prima' +import LiveDashboard from './live_dashboard' import topbar from 'topbar' /* eslint-enable import/no-unresolved */ @@ -14,8 +15,12 @@ import Alpine from 'alpinejs' let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") +let disablePushStateFlag = document.querySelector( + "meta[name='live-socket-disable-push-state']" +) +let domain = document.querySelector("meta[name='dashboard-domain']") if (csrfToken && websocketUrl) { - let Hooks = { Modal, Dropdown } + let Hooks = { Modal, Dropdown, LiveDashboard } Hooks.Metrics = { mounted() { this.handleEvent('send-metrics', ({ event_name }) => { @@ -48,9 +53,13 @@ if (csrfToken && websocketUrl) { let token = csrfToken.getAttribute('content') let url = websocketUrl.getAttribute('content') let liveUrl = url === '' ? '/live' : new URL('/live', url).href + let disablePushState = + !!disablePushStateFlag && + disablePushStateFlag.getAttribute('content') === 'true' + let domainName = domain && domain.getAttribute('content') let liveSocket = new LiveSocket(liveUrl, Socket, { + disablePushState: disablePushState, heartbeatIntervalMs: 10000, - params: { _csrf_token: token }, hooks: Hooks, uploaders: Uploaders, dom: { @@ -60,6 +69,18 @@ if (csrfToken && websocketUrl) { Alpine.clone(from, to) } } + }, + params: () => { + if (domainName) { + return { + user_prefs: { + page_tab: localStorage.getItem(`pageTab__${domainName}`) + }, + _csrf_token: token + } + } else { + return { _csrf_token: token } + } } }) diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index a1aac36cdffa..4f0c39f160be 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -413,6 +413,23 @@ defmodule PlausibleWeb.Components.Generic do end end + attr(:href, :string, required: true) + attr(:class, :string, default: "") + attr(:rest, :global) + slot(:inner_block, required: true) + + def dashboard_link(assigns) do + ~H""" + <.link + data-type="dashboard-link" + href={@href} + {@rest} + > + {render_slot(@inner_block)} + + """ + end + attr(:class, :any, default: "") attr(:rest, :global) diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 07dc1412b10c..79a9cd53ee52 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -52,7 +52,7 @@ defmodule PlausibleWeb.StatsController do plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats, :csv_export]) - def stats(%{assigns: %{site: site}} = conn, _params) do + def stats(%{assigns: %{site: site}} = conn, params) do site = Plausible.Repo.preload(site, :owners) site_role = conn.assigns[:site_role] current_user = conn.assigns[:current_user] @@ -100,7 +100,9 @@ defmodule PlausibleWeb.StatsController do hide_footer?: if(ce?() || demo, do: false, else: site_role != :public), consolidated_view?: consolidated_view?, consolidated_view_available?: consolidated_view_available?, - team_identifier: team_identifier + team_identifier: team_identifier, + connect_live_socket: true, + params: params ) !stats_start_date && can_see_stats? -> diff --git a/lib/plausible_web/live/components/prima_modal.ex b/lib/plausible_web/live/components/prima_modal.ex index 381586d21833..0886bbca687d 100644 --- a/lib/plausible_web/live/components/prima_modal.ex +++ b/lib/plausible_web/live/components/prima_modal.ex @@ -6,10 +6,11 @@ defmodule PlausibleWeb.Live.Components.PrimaModal do attr :id, :string, required: true attr :use_portal?, :boolean, default: Mix.env() not in [:test, :ce_test] slot :inner_block, required: true + attr :on_close, JS, default: %JS{} def modal(assigns) do ~H""" - + domain, "url" => url} = session, socket) do + user_prefs = get_connect_params(socket)["user_prefs"] || %{} + current_user = socket.assigns[:current_user] + + site = + current_user + |> Plausible.Sites.get_for_user(domain) + |> Plausible.Repo.preload(:owners) + + socket = + socket + |> assign(:site, site) + |> assign(:user_prefs, user_prefs) + |> assign(:modal, nil) + + params = Map.drop(session, ["domain", "site_id", "url"]) + + {:noreply, socket} = handle_params_internal(params, url, socket) + + {:ok, assign(socket, params: params)} + end + + def handle_params_internal(params, url, socket) do + uri = URI.parse(url) + + filters = + (uri.query || "") + |> String.split("&") + |> Enum.map(&parse_filter/1) + |> Enum.filter(&Function.identity/1) + |> Jason.encode!() + + params = Map.put(params, "filters", filters) + + query = Query.from(socket.assigns.site, params, %{}) + + modal = uri.path |> String.split("/") |> Enum.at(2) + + socket = + if modal do + socket + |> Prima.Modal.push_open("details-#{modal}-breakdown-modal") + |> assign(:modal, modal) + else + socket + |> Prima.Modal.push_close("details-#{modal}-breakdown-modal") + |> assign(:modal, nil) + end + + socket = assign(socket, :query, query) + + {:noreply, socket} + end + + def render(assigns) do + ~H""" +
+ <.portal id="pages-breakdown-live-container" target="#pages-breakdown-live"> + <.live_component + module={PlausibleWeb.Live.Dashboard.Pages} + id="pages-breakdown-component" + params={@params} + site={@site} + query={@query} + user_prefs={@user_prefs} + > + + + <.live_component + module={PlausibleWeb.Live.Dashboard.Details.Pages} + id="details-pages-breakdown-component" + params={@params} + site={@site} + query={@query} + user_prefs={@user_prefs} + > + +
+ """ + end + + def handle_event("handle_dashboard_params", %{"url" => url}, socket) do + query = + url + |> URI.parse() + |> Map.fetch!(:query) + + params = URI.decode_query(query || "") + + handle_params_internal(params, url, socket) + end + + defp parse_filter("f=" <> filter_expr) do + case String.split(filter_expr, ",") do + ["is", metric, value] when metric in ["page"] -> + [:is, "event:#{metric}", [value]] + + ["is", metric, value] -> + [:is, "visit:#{metric}", [value]] + + _ -> + nil + end + end + + defp parse_filter(_), do: nil +end diff --git a/lib/plausible_web/live/dashboard/details/pages.ex b/lib/plausible_web/live/dashboard/details/pages.ex new file mode 100644 index 000000000000..1442c41d2502 --- /dev/null +++ b/lib/plausible_web/live/dashboard/details/pages.ex @@ -0,0 +1,33 @@ +defmodule PlausibleWeb.Live.Dashboard.Details.Pages do + @moduledoc """ + Component for detailed pages breakdown. + """ + + use PlausibleWeb, :live_component + + alias PlausibleWeb.Live.Components.PrimaModal + + def update(_assigns, socket) do + {:ok, socket} + end + + def render(assigns) do + ~H""" +
+ +
+ + Pages + +
+
+ Some content will come here real soon now. +
+
+
+ """ + end +end diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex new file mode 100644 index 000000000000..300383ba730d --- /dev/null +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -0,0 +1,366 @@ +defmodule PlausibleWeb.Live.Dashboard.Pages do + @moduledoc """ + LV version of pages breakdown. + """ + + use PlausibleWeb, :live_component + + alias Plausible.Stats + alias Plausible.Stats.Filters + + @max_items 9 + @min_height 380 + @row_height 32 + @row_gap_height 4 + @data_container_height (@row_height + @row_gap_height) * (@max_items - 1) + @row_height + @col_min_width 70 + + @name_labels %{ + "pages" => "Page", + "entry-pages" => "Entry Page", + "exit-pages" => "Exit Page" + } + + @metrics %{ + "pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Visitors", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + }, + "entry-pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Unique Entrances", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + }, + "exit-pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Unique Exits", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + } + } + + def update(assigns, socket) do + active_tab = assigns.user_prefs["page_tab"] || "pages" + + socket = + socket + |> assign( + name_labels: @name_labels, + max_items: @max_items, + min_height: @min_height, + row_height: @row_height, + row_gap_height: @row_gap_height, + data_container_height: @data_container_height, + col_min_width: @col_min_width, + site: assigns.site, + active_tab: active_tab, + query: assigns.query, + user_prefs: Map.get(socket.assigns, :user_prefs, assigns.user_prefs) + ) + |> load_metrics() + + {:ok, socket} + end + + def render(assigns) do + tabs = [ + %{label: "Top Pages", value: "pages"}, + %{label: "Entry Pages", value: "entry-pages"}, + %{label: "Exit Pages", value: "exit-pages"} + ] + + assigns = assign(assigns, :tabs, tabs) + + ~H""" +
+
+
+

+ Top Pages +

+
+ + <.tabs> + <.tab + :for={tab <- @tabs} + label={tab.label} + value={tab.value} + active={@active_tab} + myself={@myself} + /> + +
+ +
+
+ No data yet +
+
+ +
+
+
+
+ {@name_labels[@active_tab]} +
+ {metric.label} +
+
+
+ +
+
+
+
+
+ + <.dashboard_link + id={"filter-link-#{idx}"} + href={"/#{@site.domain}?f=is,page,#{item.name}"} + > + {trim_name(item.name, @col_min_width)} + + +
+
+
+ + {item[metric.key]} + +
+
+
+
+ + +
+
+
+ """ + end + + def handle_event("set-tab", %{"tab" => tab}, socket) do + if tab != socket.assigns.active_tab do + socket = + socket + |> assign(:active_tab, tab) + |> push_event("update_local_storage", %{ + key: "pageTab__#{socket.assigns.site.domain}", + value: tab + }) + |> load_metrics() + + {:noreply, socket} + else + {:noreply, socket} + end + end + + def tabs(assigns) do + ~H""" +
+ {render_slot(@inner_block)} +
+ """ + end + + def tab(assigns) do + ~H""" + + """ + end + + defp load_metrics(socket) do + %{results: pages, meta: meta, metrics: metrics} = + metrics_for_tab(socket.assigns.active_tab, socket.assigns.site, socket.assigns.query) + + assign( + socket, + metrics: Enum.map(metrics, &Map.fetch!(@metrics[socket.assigns.active_tab], &1)), + results: Enum.take(pages, @max_items), + meta: Map.merge(meta, Stats.Breakdown.formatted_date_ranges(socket.assigns.query)), + skip_imported_reason: meta[:imports_skip_reason] + ) + end + + defp metrics_for_tab("pages", site, query) do + query = struct!(query, dimensions: ["event:page"]) + + metrics = breakdown_metrics(query) + pagination = parse_pagination(%{}) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination) + + pages = + results + |> transform_keys(%{page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp metrics_for_tab("entry-pages", site, query) do + query = struct!(query, dimensions: ["visit:entry_page"]) + + metrics = breakdown_metrics(query) + pagination = parse_pagination(%{}) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination) + + pages = + results + |> transform_keys(%{entry_page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp metrics_for_tab("exit-pages", site, query) do + query = struct!(query, dimensions: ["visit:exit_page"]) + + metrics = breakdown_metrics(query) + pagination = parse_pagination(%{}) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination) + + pages = + results + |> transform_keys(%{exit_page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp trim_name(name, max_length) do + if String.length(name) <= max_length do + name + else + left_length = div(max_length, 2) + right_length = max_length - left_length + + left_side = String.slice(name, 0..left_length) + right_side = String.slice(name, -right_length..-1) + + left_side <> "..." <> right_side + end + end + + defp parse_pagination(params) do + limit = to_int(params["limit"], 9) + page = to_int(params["page"], 1) + {limit, page} + end + + defp breakdown_metrics(query) do + if toplevel_goal_filter?(query) do + [:visitors, :conversion_rate] + else + [:visitors] + end + end + + defp transform_keys(result, keys_to_replace) when is_map(result) do + for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{} + end + + defp transform_keys(results, keys_to_replace) when is_list(results) do + Enum.map(results, &transform_keys(&1, keys_to_replace)) + end + + defp to_int(string, default) when is_binary(string) do + case Integer.parse(string) do + {i, ""} when is_integer(i) -> + i + + _ -> + default + end + end + + defp to_int(_, default), do: default + + defp toplevel_goal_filter?(query) do + Filters.filtering_on_dimension?(query, "event:goal", + max_depth: 0, + behavioral_filters: :ignore + ) + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index a5046072e320..420f03ae7b7f 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do put "/:domain/settings", SiteController, :update_settings get "/:domain/export", StatsController, :csv_export - get "/:domain/*path", StatsController, :stats + + scope assigns: %{live_socket_disable_push_state: true} do + get "/:domain/*path", StatsController, :stats + end end end end diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex index dc217ba23fa9..99e7c11341ed 100644 --- a/lib/plausible_web/templates/layout/app.html.heex +++ b/lib/plausible_web/templates/layout/app.html.heex @@ -12,6 +12,12 @@ <% end %> + <%= if assigns[:live_socket_disable_push_state] do %> + + <% end %> + <%= if assigns[:site] do %> + + <% end %> diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index d473e6b5be41..4ca5a170ad3b 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -54,6 +54,15 @@ data-team-identifier={@team_identifier} > + {live_render(@conn, PlausibleWeb.Live.Dashboard, + id: "live-dashboard-lv", + session: + Map.merge(@params, %{ + "site_id" => @site.id, + "domain" => @site.domain, + "url" => Plug.Conn.request_url(@conn) + }) + )} <%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
diff --git a/mix.exs b/mix.exs index 6b85b8ff9406..c7c8e584ab80 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,10 @@ defmodule Plausible.MixProject do {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]}, {:phoenix_pubsub, "~> 2.0"}, - {:phoenix_live_view, "~> 1.1"}, + {:phoenix_live_view, + git: "https://github.com/plausible/phoenix_live_view", + branch: "disable-push-state", + override: true}, {:php_serializer, "~> 2.0"}, {:plug, "~> 1.13", override: true}, {:prima, "~> 0.2.1"}, diff --git a/mix.lock b/mix.lock index c583d621ce4d..8a686c68ecf8 100644 --- a/mix.lock +++ b/mix.lock @@ -118,13 +118,13 @@ "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "peep": {:hex, :peep, "3.4.2", "49d4ca116d994779351959dfee971fb2c7d6506f1821374f3cc4fd39a3d3fadb", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "cae224b09e224bf5584f5375108becf71e2288572a099a122e66735289cd33f4"}, - "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, + "phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"}, "phoenix_bakery": {:hex, :phoenix_bakery, "0.1.2", "ca57673caea1a98f1cc763f94032796a015774d27eaa3ce5feef172195470452", [:mix], [{:brotli, "~> 0.3.0", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "45cc8cecc5c3002b922447c16389761718c07c360432328b04680034e893ea5b"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.17", "1d782b5901cf13b137c6d8c56542ff6cb618359b2adca7e185b21df728fa0c6c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa82307dd9305657a8236d6b48e60ef2e8d9f742ee7ed832de4b8bcb7e0e5ed2"}, + "phoenix_live_view": {:git, "https://github.com/plausible/phoenix_live_view", "492a8aeebe450c9c73be50f2c5cf6aea1ca7006f", [branch: "disable-push-state"]}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_storybook": {:hex, :phoenix_storybook, "0.9.3", "4f94e731d4c40d4dd7d1eddf7d5c6914366da7d78552dc565b222e4036d0d76f", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}, {:makeup_eex, "~> 2.0.2", [hex: :makeup_eex, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8.1", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "4c8658b756fd8238f7e8e4343a0f12bdb91d4eba592b1c4e8118b37b6fd43e4b"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},