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"""
<.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}>
<.favicon domain={@site.domain} />
<%= @site.domain %>
<.site_stats hourly_stats={@hourly_stats} />
<.ellipsis_menu site={@site} />
"""
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"""
<%= PlausibleWeb.StatsView.large_number_format(@hourly_stats.visitors) %>
visitors in last 24h
<.percentage_change change={@hourly_stats.change} />
"""
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"""
Close
Invitation for
You've been invited to the
analytics dashboard as Admin .
If you accept the ownership transfer, you will be responsible for billing going forward.
<.notice
x-show="selectedInvitation && selectedInvitation.missing_features"
title="Missing features"
class="mt-4 shadow-sm dark:shadow-none"
>
The site uses ,
which your current subscription does not support. After accepting ownership of this site,
you will not be able to access them unless you
<.styled_link
class="inline-block"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
>
upgrade to a suitable plan
.
<.notice
x-show="selectedInvitation && selectedInvitation.exceeded_limits"
title="Exceeded limits"
class="mt-4 shadow-sm dark:shadow-none"
>
You are unable to accept the ownership of this site because doing so would exceed the
of your subscription.
You can review your usage in the
<.styled_link
class="inline-block"
href={Routes.auth_path(PlausibleWeb.Endpoint, :user_settings)}
>
account settings
.
To become the owner of this site, you should either reduce your usage, or upgrade your subscription.
<.notice
x-show="selectedInvitation && selectedInvitation.no_plan"
title="No subscription"
class="mt-4 shadow-sm dark:shadow-none"
>
You are unable to accept the ownership of this site because your account does not have a subscription. To become the owner of this site, you should upgrade to a suitable plan.
<.button
x-show="selectedInvitation && !(selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
data-method="post"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation.invitation_id + '/accept')"
>
Accept & Continue
<.button_link
x-show="selectedInvitation && (selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
>
Upgrade
Reject
"""
end
attr :filter_text, :string, default: ""
attr :uri, URI, required: true
def search_form(assigns) do
~H"""
"""
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, ce?()) 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