Current section

Files

Jump to
livebook lib livebook_web live apps_live.ex
Raw

lib/livebook_web/live/apps_live.ex

defmodule LivebookWeb.AppsLive do
use LivebookWeb, :live_view
import LivebookWeb.AppComponents
@app_folder_events [
:app_folder_created,
:app_folder_updated,
:app_folder_deleted
]
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
Livebook.Apps.subscribe()
end
empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok,
socket
|> assign(
apps_banner: Livebook.Config.apps_banner(),
search_term: "",
selected_app_folder: "",
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
empty_apps_path?: empty_apps_path?,
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
|> load_app_folders()
|> apply_filters()}
end
@impl true
def render(assigns) do
~H"""
<div class="h-full flex flex-col overflow-y-auto bg-white">
<.apps_banner value={@apps_banner} />
<div class="border-b border-gray-200/70 bg-white/80 backdrop-blur-sm shadow-sm">
<div class="max-w-6xl mx-auto px-10 py-3.5 flex items-center justify-between">
<div class="w-10 h-10">
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
<:toggle>
<button class="flex items-center text-gray-900">
<img src={~p"/images/logo.png"} height="40" width="40" alt="logo livebook" />
<.remix_icon :if={@logout_enabled?} icon="arrow-down-s-line" />
</button>
</:toggle>
<.menu_item :if={@logout_enabled?}>
<button phx-click="logout" role="menuitem">
<.remix_icon icon="logout-box-line" />
<span>Logout</span>
</button>
</.menu_item>
</.menu>
</div>
<div>
<.link
navigate={~p"/apps-dashboard"}
class="inline-flex items-center gap-1.5 text-gray-500 text-xs font-medium px-3 py-1.5 rounded-md hover:bg-gray-100/80 hover:text-gray-700 transition-all duration-200"
>
<span>Dashboard</span>
<.remix_icon
icon="arrow-right-line"
class="text-xs text-gray-400"
/>
</.link>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="max-w-6xl mx-auto px-12 py-12 pb-24">
<div class="mb-6">
<h1 class="text-3xl font-semibold text-gray-900 tracking-tight leading-none m-0">
Apps
</h1>
</div>
<%= if @apps != [] do %>
<div class="flex flex-col md:flex-row gap-4 mb-10">
<div class="flex-1">
<div class="relative">
<.remix_icon
icon="search-line"
class="absolute left-3 bottom-2 text-gray-400"
/>
<.text_field
id="search-app"
name="search_term"
placeholder="Search by app name..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
class="w-full pl-10 text-base"
autofocus
/>
</div>
</div>
<div :if={@show_app_folders?} class="md:w-48">
<form id="select-app-folder-form" phx-change="select_app_folder" phx-nosubmit>
<.select_field
id="select-app-folder"
name="app_folder"
prompt="All folders"
value={@selected_app_folder}
options={@app_folder_options}
/>
</form>
</div>
</div>
<div :if={@filtered_apps == []} class="text-center py-20 px-5">
<div class="text-5xl text-gray-300 mb-4">
<.remix_icon icon="windy-line" />
</div>
<div class="text-base font-semibold text-gray-900 mb-2">No apps found</div>
<div class="text-sm text-gray-500">Try adjusting your search or filter</div>
</div>
<div :if={@filtered_apps != []} class="flex flex-col gap-12">
<div :for={{app_folder, id, icon, apps} <- @grouped_apps} id={id}>
<%= if @show_app_folders? do %>
<div class="flex items-center gap-2.5 mb-5 pb-2.5 border-b border-gray-200/70">
<.remix_icon icon={icon} class="text-gray-500/80 text-xs leading-none" />
<span class="text-sm text-gray-500 flex-1 tracking-widest leading-none">
{app_folder}
</span>
</div>
<% end %>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<.link
:for={app <- apps_listing(apps)}
id={"app-#{app.slug}"}
navigate={~p"/apps/#{app.slug}"}
class="group block border border-gray-200 rounded-lg px-4 py-4 bg-white shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 ease-out cursor-pointer"
>
<div class="flex items-center justify-between gap-3">
<span class="text-base font-semibold text-gray-800 flex-1 truncate group-hover:text-gray-900">
{app.notebook_name}
</span>
<div class="flex items-center gap-1.5 shrink-0">
<.remix_icon
:if={not app.public?}
icon="lock-password-line"
class="text-sm text-gray-300"
/>
</div>
</div>
</.link>
</div>
</div>
</div>
<% else %>
<div class="max-w-2xl mx-auto mt-16 text-center">
<div :if={@empty_apps_path?}>
<div class="mb-8">
<.remix_icon icon="windy-line" class="text-gray-300 text-5xl mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">No apps found</h3>
<p class="text-sm text-gray-500 mb-6">
Follow these steps to list your apps here:
</p>
</div>
<div class="max-w-md mx-auto">
<ol class="space-y-3">
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0">
1
</span>
<span>Open a notebook</span>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0 mt-px">
2
</span>
<div class="flex gap-x-1 items-center flex-wrap">
Click <.remix_icon icon="rocket-line" class="inline align-middle text-base" />
in the sidebar and configure the app as public
</div>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0 mt-px">
3
</span>
<div class="flex gap-x-1 items-center flex-wrap">
Save the notebook to the
<code class="inline-block px-1.5 py-0.5 bg-gray-100 text-gray-900 rounded text-xs font-mono leading-none">
{Livebook.Config.apps_path()}
</code>
folder
</div>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0">
4
</span>
<span>Relaunch your Livebook app</span>
</li>
</ol>
</div>
</div>
<div :if={not @empty_apps_path?} class="text-center">
<.remix_icon icon="windy-line" class="text-gray-300 text-3xl mb-4" />
<h3 class="text-lg font-semibold text-gray-900">No apps running</h3>
</div>
</div>
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
{:noreply,
socket
|> assign(search_term: search_term)
|> apply_filters()}
end
def handle_event("select_app_folder", %{"app_folder" => app_folder_id}, socket) do
{:noreply,
socket
|> assign(selected_app_folder: app_folder_id)
|> apply_filters()}
end
@impl true
def handle_info({type, _app}, socket) when type in [:app_created, :app_updated, :app_closed] do
{:noreply,
socket
|> assign(apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user))
|> apply_filters()}
end
def handle_info({:server_authorization_updated, _}, socket) do
{:noreply,
socket
|> assign(
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
|> apply_filters()}
end
def handle_info({type, _app_folder}, socket) when type in @app_folder_events do
{:noreply,
socket
|> load_app_folders()
|> apply_filters()}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name)
end
def load_app_folders(socket) do
app_folders =
Enum.flat_map(Livebook.Hubs.get_hubs(), &Livebook.Hubs.Provider.get_app_folders/1)
app_folder_options =
for app_folder <- app_folders do
{app_folder.name, app_folder.id}
end
assign(socket, app_folders: app_folders, app_folder_options: app_folder_options)
end
defp apply_filters(socket) do
apps = socket.assigns.apps
app_folders = socket.assigns.app_folders
filtered_apps =
filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder)
grouped_apps =
filtered_apps
|> Enum.group_by(fn
%{app_spec: %{app_folder_id: id}} -> Enum.find_value(app_folders, &(&1.id == id && id))
_ -> nil
end)
|> Enum.map(fn
{nil, apps} ->
{"No folder", "ungrouped-apps", "function-line", apps}
{id, apps} ->
app_folder_name = Enum.find_value(app_folders, &(&1.id == id && &1.name))
{app_folder_name, "app-folder-#{id}", "folder-line", apps}
end)
|> Enum.sort_by(fn {name, _, _, _} ->
if name == "No folder", do: {1, name}, else: {0, name}
end)
show_app_folders? = Enum.any?(apps, &is_struct(&1.app_spec, Livebook.Apps.TeamsAppSpec))
assign(socket,
grouped_apps: grouped_apps,
filtered_apps: filtered_apps,
show_app_folders?: show_app_folders?
)
end
defp filter_apps(apps, term, app_folder_id) do
apps
|> search_apps(term)
|> filter_by_app_folder(app_folder_id)
end
defp search_apps(apps, ""), do: apps
defp search_apps(apps, term) do
term = String.downcase(term)
Enum.filter(apps, fn app ->
String.contains?(String.downcase(app.notebook_name), term) or
String.contains?(app.slug, term)
end)
end
defp filter_by_app_folder(apps, ""), do: apps
defp filter_by_app_folder(apps, app_folder_id) do
Enum.filter(apps, fn
%{app_spec: %{app_folder_id: id}} -> id == app_folder_id
_otherwise -> false
end)
end
end