defmodule PlausibleWeb.Live.Sites do @moduledoc """ LiveView for sites index. """ use PlausibleWeb, :live_view use Phoenix.HTML import PlausibleWeb.Components.Generic import PlausibleWeb.Live.Components.Pagination alias Plausible.Auth alias Plausible.Repo alias Plausible.Site alias Plausible.Sites alias Plausible.Site.Memberships.Invitations def mount(params, %{"current_user_id" => user_id}, socket) do uri = ("/sites?" <> URI.encode_query(Map.take(params, ["filter_text"]))) |> URI.new!() socket = socket |> assign(:uri, uri) |> assign(:filter_text, params["filter_text"] || "") |> assign(:user, Repo.get!(Auth.User, user_id)) {:ok, socket} end def handle_params(params, _uri, socket) do socket = socket |> assign(:params, params) |> load_sites() |> assign_new(:has_sites?, fn %{user: user} -> Site.Memberships.any_or_pending?(user) end) |> assign_new(:needs_to_upgrade, fn %{user: user, sites: sites} -> user_owns_sites = Enum.any?(sites.entries, fn site -> List.first(site.memberships ++ site.invitations).role == :owner end) || Auth.user_owns_sites?(user) user_owns_sites && Plausible.Billing.check_needs_to_upgrade(user) end) {:noreply, socket} end def render(assigns) do ~H""" <.flash_messages flash={@flash} />
Enum.into(%{}) |> Jason.encode!}}"} x-on:keydown.escape.window="invitationOpen = false" class="container pt-6" > <.upgrade_nag_screen :if={@needs_to_upgrade == {:needs_to_upgrade, :no_active_subscription}} />

My Sites

<.search_form :if={@has_sites?} filter_text={@filter_text} uri={@uri} />

You don't have any sites yet.

No sites found. Please search for something else.

    <%= for site <- @sites.entries do %> <.site :if={site.entry_type in ["pinned_site", "site"]} site={site} hourly_stats={@hourly_stats[site.domain]} /> <.invitation :if={site.entry_type == "invitation"} site={site} invitation={hd(site.invitations)} hourly_stats={@hourly_stats[site.domain]} /> <% end %>
<.pagination :if={@sites.total_pages > 1} id="sites-pagination" uri={@uri} page_number={@sites.page_number} total_pages={@sites.total_pages} > Total of <%= @sites.total_entries %> sites <.invitation_modal :if={Enum.any?(@sites.entries, &(&1.entry_type == "invitation"))} user={@user} />
""" end def upgrade_nag_screen(assigns) do ~H"""

Payment required

To access the sites you own, you need to subscribe to a monthly or yearly payment plan. <%= link( "Upgrade now →", to: "/settings", class: "text-sm font-medium text-yellow-800" ) %>

""" end attr :site, Plausible.Site, required: true attr :invitation, Plausible.Auth.Invitation, required: true attr :hourly_stats, :map, required: true def invitation(assigns) do ~H"""
  • <%= @site.domain %>

    Pending invitation
    <.site_stats hourly_stats={@hourly_stats} />
  • """ end attr :site, Plausible.Site, required: true attr :hourly_stats, :map, required: true def site(assigns) do ~H""" """ end def ellipsis_menu(assigns) do ~H""" <.dropdown> <:button class="absolute top-0 right-0 h-10 w-10 rounded-md hover:cursor-pointer text-gray-400 dark:text-gray-600 hover:text-black dark:hover:text-indigo-400"> <:panel class="absolute top-7 right-3 z-10 mt-2 w-40 rounded-md bg-white dark:bg-gray-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
    <.dropdown_link :if={List.first(@site.memberships).role != :viewer} href={"/#{URI.encode_www_form(@site.domain)}/settings/general"} > Settings <.dropdown_link href="#" x-on:click.prevent phx-click={ JS.hide( transition: {"duration-500", "opacity-100", "opacity-0"}, to: "#site-card-#{hash_domain(@site.domain)}", time: 500 ) |> JS.push("pin-toggle") } phx-value-domain={@site.domain} > <.icon_pin :if={@site.pinned_at} class="pt-1 mr-3 h-5 w-5 text-red-400 stroke-red-500 dark:text-yellow-600 dark:stroke-yellow-700" /> Unpin Site <.icon_pin :if={!@site.pinned_at} class="pt-1 mr-3 h-5 w-5" /> Pin Site
    """ end attr :rest, :global def icon_pin(assigns) do ~H""" """ end attr :hourly_stats, :map, required: true def site_stats(assigns) do ~H"""
    """ end attr :change, :integer, required: true def percentage_change(assigns) do ~H"""

    0} class="font-semibold text-green-500">↑ <%= abs(@change) %>%

    """ end attr :user, Plausible.Auth.User, required: true def invitation_modal(assigns) do ~H""" """ end attr :filter_text, :string, default: "" attr :uri, URI, required: true def search_form(assigns) do ~H"""
    <.spinner class="hidden phx-change-loading:inline ml-2" />
    """ end def favicon(assigns) do src = "/favicon/sources/#{assigns.domain}" assigns = assign(assigns, :src, src) ~H""" """ end def handle_event("pin-toggle", %{"domain" => domain}, socket) do site = Enum.find(socket.assigns.sites.entries, &(&1.domain == domain)) if site do socket = case Sites.toggle_pin(socket.assigns.user, site) do {:ok, preference} -> flash_message = if preference.pinned_at do "Site pinned" else "Site unpinned" end socket |> put_live_flash(:success, flash_message) |> load_sites() |> push_event("js-exec", %{ to: "#site-card-#{hash_domain(site.domain)}", attr: "data-pin-toggled" }) {:error, :too_many_pins} -> flash_message = "Looks like you've hit the pinned sites limit! " <> "Please unpin one of your pinned sites to make room for new pins" socket |> put_live_flash(:error, flash_message) |> push_event("js-exec", %{ to: "#site-card-#{hash_domain(site.domain)}", attr: "data-pin-failed" }) end {:noreply, socket} else Sentry.capture_message("Attempting to toggle pin for invalid domain.", extra: %{domain: domain, user: socket.assigns.user.id} ) {:noreply, socket} end end def handle_event( "filter", %{"filter_text" => filter_text}, %{assigns: %{filter_text: filter_text}} = socket ) do {:noreply, socket} end def handle_event("filter", %{"filter_text" => filter_text}, socket) do socket = socket |> reset_pagination() |> set_filter_text(filter_text) {:noreply, socket} end def handle_event("reset-filter-text", _params, socket) do socket = socket |> reset_pagination() |> set_filter_text("") {:noreply, socket} end defp load_sites(%{assigns: assigns} = socket) do sites = Sites.list_with_invitations(assigns.user, assigns.params, filter_by_domain: assigns.filter_text ) hourly_stats = if connected?(socket) do Plausible.Stats.Clickhouse.last_24h_visitors_hourly_intervals(sites.entries) else sites.entries |> Enum.into(%{}, fn site -> {site.domain, :loading} end) end invitations = extract_invitations(sites.entries, assigns.user) assign( socket, sites: sites, invitations: invitations, hourly_stats: hourly_stats ) end defp extract_invitations(sites, user) do sites |> Enum.filter(&(&1.entry_type == "invitation")) |> Enum.flat_map(& &1.invitations) |> Enum.map(&check_limits(&1, user)) end defp check_limits(%{role: :owner, site: site} = invitation, user) do case Invitations.ensure_can_take_ownership(site, user) do :ok -> check_features(invitation, user) {:error, :no_plan} -> %{invitation: invitation, no_plan: true} {:error, {:over_plan_limits, limits}} -> limits = PlausibleWeb.TextHelpers.pretty_list(limits) %{invitation: invitation, exceeded_limits: limits} end end defp check_limits(invitation, _), do: %{invitation: invitation} defp check_features(%{role: :owner, site: site} = invitation, user) do case Invitations.check_feature_access(site, user, small_build?()) do :ok -> %{invitation: invitation} {:error, {:missing_features, features}} -> feature_names = features |> Enum.map(& &1.display_name()) |> PlausibleWeb.TextHelpers.pretty_list() %{invitation: invitation, missing_features: feature_names} end end defp set_filter_text(socket, filter_text) do uri = socket.assigns.uri uri_params = uri.query |> URI.decode_query() |> Map.put("filter_text", filter_text) |> URI.encode_query() uri = %{uri | query: uri_params} socket |> assign(:filter_text, filter_text) |> assign(:uri, uri) |> push_patch(to: URI.to_string(uri), replace: true) end defp reset_pagination(socket) do pagination_fields = ["page"] uri = socket.assigns.uri uri_params = uri.query |> URI.decode_query() |> Map.drop(pagination_fields) |> URI.encode_query() assign(socket, uri: %{uri | query: uri_params}, params: Map.drop(socket.assigns.params, pagination_fields) ) end defp hash_domain(domain) do :sha |> :crypto.hash(domain) |> Base.encode16() end end