mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-07 21:44:36 +08:00
Add basic session UI
This commit is contained in:
parent
e31e14d753
commit
4b1a86527f
9 changed files with 279 additions and 30 deletions
|
@ -4,3 +4,14 @@
|
|||
|
||||
@import "../node_modules/nprogress/nprogress.css";
|
||||
@import "./live_view.css";
|
||||
|
||||
/* Remove the default outline on focused elements */
|
||||
:focus,
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Hide Phoenix live reload frame */
|
||||
iframe[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -87,6 +87,9 @@ defmodule LiveBookWeb do
|
|||
|
||||
import LiveBookWeb.ErrorHelpers
|
||||
alias LiveBookWeb.Router.Helpers, as: Routes
|
||||
|
||||
# Custom helpers
|
||||
alias LiveBookWeb.Icons
|
||||
end
|
||||
end
|
||||
|
||||
|
|
52
lib/live_book_web/live/cell.ex
Normal file
52
lib/live_book_web/live/cell.ex
Normal file
|
@ -0,0 +1,52 @@
|
|||
defmodule LiveBookWeb.Cell do
|
||||
use LiveBookWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="<%= cell_class(@focused) %>" phx-click="focus_cell" phx-value-cell_id="<%= @cell.id %>">
|
||||
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
|
||||
<button class="text-gray-500 hover:text-current">
|
||||
<%= Icons.svg(:play, class: "h-6") %>
|
||||
</button>
|
||||
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
|
||||
<%= Icons.svg(:trash, class: "h-6") %>
|
||||
</button>
|
||||
<%= cell_type_icon(@cell.type) %>
|
||||
</div>
|
||||
<div
|
||||
id="cell-<%= @cell.id %>"
|
||||
phx-hook="Editor"
|
||||
phx-update="ignore"
|
||||
data-id="<%= @cell.id %>"
|
||||
data-type="<%= @cell.type %>"
|
||||
>
|
||||
<div class="h-20 flex opacity-20">Editor placeholder</div>
|
||||
</div>
|
||||
<%= for output <- @cell.outputs do %>
|
||||
<div class="p-2">
|
||||
<%= render_output(output) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_class(focused) do
|
||||
base = "flex flex-col p-2 border-2 border-gray-200 rounded border-opacity-0 relative mr-10"
|
||||
if focused, do: base <> " border-opacity-100", else: base
|
||||
end
|
||||
|
||||
defp render_output(_), do: nil
|
||||
|
||||
defp cell_type_icon(:elixir) do
|
||||
~e"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path fill="#8251A8" d="M16.457 27.304c-4.118 0-7.457-3.971-7.457-8.87 0-4.012 2.96-8.915 5.244-11.901C15.326 5.119 17.371 4 17.371 4s-1.048 5.714 1.794 7.984c2.523 2.014 4.38 4.635 4.38 6.94 0 4.618-2.97 8.38-7.088 8.38z"/></svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_type_icon(:markdown) do
|
||||
~e"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path fill="#EE9D5C" fill-rule="evenodd" d="M17.74 21.419h-3.48v-5.416l-2.631 3.463-2.631-3.463v5.416h-3.48V10.586h3.48l2.63 3.625 2.632-3.625h3.48V21.42zm5.223.874L18.63 16h2.631v-5.416h3.48V16h2.632l-4.409 6.293h-.002zM27.99 7H4.01c-.541 0-1.012.206-1.411.617-.4.411-.599.895-.599 1.453v13.86c0 .585.2 1.076.599 1.474.4.397.87.596 1.41.596H27.99c.541 0 1.012-.199 1.411-.596.4-.398.599-.89.599-1.474V9.07c0-.558-.2-1.042-.599-1.453-.4-.411-.87-.617-1.41-.617z"/></svg>
|
||||
"""
|
||||
end
|
||||
end
|
51
lib/live_book_web/live/icons.ex
Normal file
51
lib/live_book_web/live/icons.ex
Normal file
|
@ -0,0 +1,51 @@
|
|||
defmodule LiveBookWeb.Icons do
|
||||
import Phoenix.HTML
|
||||
import Phoenix.HTML.Tag
|
||||
|
||||
@doc """
|
||||
Returns icon svg tag.
|
||||
"""
|
||||
@spec svg(atom(), keyword()) :: Phoenix.HTML.safe()
|
||||
def svg(name, attrs \\ [])
|
||||
|
||||
def svg(:chevron_right, attrs) do
|
||||
~e"""
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
"""
|
||||
|> heroicon_svg(attrs)
|
||||
end
|
||||
|
||||
def svg(:play, attrs) do
|
||||
~e"""
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
"""
|
||||
|> heroicon_svg(attrs)
|
||||
end
|
||||
|
||||
def svg(:plus, attrs) do
|
||||
~e"""
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
"""
|
||||
|> heroicon_svg(attrs)
|
||||
end
|
||||
|
||||
def svg(:trash, attrs) do
|
||||
~e"""
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
"""
|
||||
|> heroicon_svg(attrs)
|
||||
end
|
||||
|
||||
# https://heroicons.com
|
||||
defp heroicon_svg(svg_content, attrs) do
|
||||
heroicon_svg_attrs = [
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
fill: "none",
|
||||
viewBox: "0 0 24 24",
|
||||
stroke: "currentColor"
|
||||
]
|
||||
|
||||
content_tag(:svg, svg_content, Keyword.merge(attrs, heroicon_svg_attrs))
|
||||
end
|
||||
end
|
34
lib/live_book_web/live/insert_cell_actions.ex
Normal file
34
lib/live_book_web/live/insert_cell_actions.ex
Normal file
|
@ -0,0 +1,34 @@
|
|||
defmodule LiveBookWeb.InsertCellActions do
|
||||
use LiveBookWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="opacity-0 hover:opacity-100 flex space-x-2 justify-center items-center">
|
||||
<%= line() %>
|
||||
<button
|
||||
phx-click="insert_cell"
|
||||
phx-value-type="markdown"
|
||||
phx-value-section_id="<%= @section_id %>"
|
||||
phx-value-index="<%= @index %>"
|
||||
class="py-1 px-2 rounded text-sm hover:bg-gray-100 border border-gray-200 bg-gray-50">
|
||||
+ Markdown
|
||||
</button>
|
||||
<button
|
||||
phx-click="insert_cell"
|
||||
phx-value-type="elixir"
|
||||
phx-value-section_id="<%= @section_id %>"
|
||||
phx-value-index="<%= @index %>"
|
||||
class="py-1 px-2 rounded text-sm hover:bg-gray-100 border border-gray-200 bg-gray-50">
|
||||
+ Elixir
|
||||
</button>
|
||||
<%= line() %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp line() do
|
||||
~e"""
|
||||
<div class="border-t-2 border-dashed border-gray-200 flex-grow"></div>
|
||||
"""
|
||||
end
|
||||
end
|
30
lib/live_book_web/live/section.ex
Normal file
30
lib/live_book_web/live/section.ex
Normal file
|
@ -0,0 +1,30 @@
|
|||
defmodule LiveBookWeb.Section do
|
||||
use LiveBookWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="<%= if not @selected, do: "hidden" %>">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex space-x-2 items-center text-gray-600">
|
||||
<%= Icons.svg(:chevron_right, class: "h-8") %>
|
||||
<h2 class="text-3xl" contenteditable spellcheck="false"><%= @section.name %></h2>
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<button phx-click="delete_section" phx-value-section_id="<%= @section.id %>" class="text-gray-600 hover:text-current">
|
||||
<%= Icons.svg(:trash, class: "h-6") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container py-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= live_component @socket, LiveBookWeb.InsertCellActions, section_id: @section.id, index: 0, id: "insert-#{@section.id}-0" %>
|
||||
<%= for {cell, index} <- Enum.with_index(@section.cells) do %>
|
||||
<%= live_component @socket, LiveBookWeb.Cell, cell: cell, id: "cell-#{cell.id}", focused: cell.id == @focused_cell_id %>
|
||||
<%= live_component @socket, LiveBookWeb.InsertCellActions, section_id: @section.id, index: index + 1, id: "insert-#{@section.id}-#{index + 1}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -13,7 +13,7 @@ defmodule LiveBookWeb.SessionLive do
|
|||
|
||||
data = Session.get_data(session_id)
|
||||
|
||||
{:ok, assign(socket, session_id: session_id, data: data)}
|
||||
{:ok, assign(socket, session_id: session_id, data: data, selected_section_id: nil, focused_cell_id: nil)}
|
||||
else
|
||||
{:ok, redirect(socket, to: Routes.live_path(socket, LiveBookWeb.SessionsLive))}
|
||||
end
|
||||
|
@ -22,12 +22,80 @@ defmodule LiveBookWeb.SessionLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="container max-w-screen-md p-4 mx-auto">
|
||||
Session <%= @session_id %>
|
||||
<div class="flex flex-grow max-h-full">
|
||||
<div class="w-1/5 bg-gray-100 border-r-2 border-gray-200">
|
||||
<h1 class="p-8 text-2xl" contenteditable spellcheck="false"><%= @data.notebook.name %></h1>
|
||||
<div class="flex flex-col space-y-2 pl-4">
|
||||
<%= for section <- @data.notebook.sections do %>
|
||||
<div phx-click="select_section" phx-value-section_id="<%= section.id %>" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-500 hover:text-current">
|
||||
<span>
|
||||
<%= section.name %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= Icons.svg(:plus, class: "h-6") %>
|
||||
<span>New section</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
|
||||
<div class="max-w-screen-lg w-full mx-auto">
|
||||
<%= for section <- @data.notebook.sections do %>
|
||||
<%= live_component @socket, LiveBookWeb.Section,
|
||||
id: "section-#{section.id}",
|
||||
section: section,
|
||||
selected: section.id == @selected_section_id,
|
||||
focused_cell_id: @focused_cell_id %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("add_section", _params, socket) do
|
||||
end_index = length(socket.assigns.data.notebook.sections)
|
||||
Session.insert_section(socket.assigns.session_id, end_index)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("delete_section", %{"section_id" => section_id}, socket) do
|
||||
Session.delete_section(socket.assigns.session_id, section_id)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_section", %{"section_id" => section_id}, socket) do
|
||||
{:noreply, assign(socket, selected_section_id: section_id)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"insert_cell",
|
||||
%{"section_id" => section_id, "index" => index, "type" => type},
|
||||
socket
|
||||
) do
|
||||
index = String.to_integer(index) |> max(0)
|
||||
type = String.to_atom(type)
|
||||
Session.insert_cell(socket.assigns.session_id, section_id, index, type)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do
|
||||
Session.delete_cell(socket.assigns.session_id, cell_id)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
|
||||
{:noreply, assign(socket, focused_cell_id: cell_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:operation, operation}, socket) do
|
||||
case Session.Data.apply_operation(socket.assigns.data, operation) do
|
||||
|
|
|
@ -22,14 +22,12 @@ defmodule LiveBookWeb.SessionsLive do
|
|||
</div>
|
||||
<%= for session_id <- Enum.sort(@session_ids) do %>
|
||||
<div class="p-3 flex">
|
||||
<div class="flex-grow text-lg hover:opacity-70">
|
||||
<div class="flex-grow text-lg text-gray-500 hover:text-current">
|
||||
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
|
||||
</div>
|
||||
<div>
|
||||
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete">
|
||||
<svg class="h-6 w-6 hover:opacity-70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete" class="text-gray-500 hover:text-current">
|
||||
<%= Icons.svg(:trash, class: "h-6") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
<nav class="flex items-center justify-between px-8 py-3 bg-purple-600">
|
||||
<div class="w-full">
|
||||
<%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
|
||||
</div>
|
||||
<ul class="flex flex-row">
|
||||
<li class="flex items-center">
|
||||
<%= live_redirect "Sessions", to: "/sessions", class: "px-3 text-xs uppercase font-bold text-white hover:opacity-75" %>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<%# <div class="flex flex-col min-h-screen"> %>
|
||||
<!--
|
||||
<nav class="flex items-center justify-between px-8 py-3 bg-purple-600">
|
||||
<div class="w-full">
|
||||
<%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
|
||||
</div>
|
||||
<ul class="flex flex-row">
|
||||
<li class="flex items-center">
|
||||
<%= live_redirect "Sessions", to: "/sessions", class: "px-3 text-xs uppercase font-bold text-white hover:opacity-75" %>
|
||||
</li>
|
||||
</ul>
|
||||
</nav> -->
|
||||
<main role="main" class="flex-grow flex flex-col align-center h-screen">
|
||||
<div>
|
||||
<p class="alert alert-info" role="alert"
|
||||
phx-click="lv:clear-flash"
|
||||
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
|
||||
|
||||
<main role="main" class="container mx-auto flex flex-col align-center">
|
||||
<div>
|
||||
<p class="alert alert-info" role="alert"
|
||||
phx-click="lv:clear-flash"
|
||||
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
|
||||
<p class="alert alert-danger" role="alert"
|
||||
phx-click="lv:clear-flash"
|
||||
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
|
||||
</div>
|
||||
|
||||
<p class="alert alert-danger" role="alert"
|
||||
phx-click="lv:clear-flash"
|
||||
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
|
||||
</div>
|
||||
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<%# </div> %>
|
||||
|
|
Loading…
Add table
Reference in a new issue