diff --git a/assets/css/app.css b/assets/css/app.css
index 20a301b09..50e984e73 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -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;
+}
diff --git a/lib/live_book_web.ex b/lib/live_book_web.ex
index 23abffb4a..ed4f8603f 100644
--- a/lib/live_book_web.ex
+++ b/lib/live_book_web.ex
@@ -87,6 +87,9 @@ defmodule LiveBookWeb do
import LiveBookWeb.ErrorHelpers
alias LiveBookWeb.Router.Helpers, as: Routes
+
+ # Custom helpers
+ alias LiveBookWeb.Icons
end
end
diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex
new file mode 100644
index 000000000..5b6e0d6ae
--- /dev/null
+++ b/lib/live_book_web/live/cell.ex
@@ -0,0 +1,52 @@
+defmodule LiveBookWeb.Cell do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+
+
+
+
+ <%= cell_type_icon(@cell.type) %>
+
+
+ <%= for output <- @cell.outputs do %>
+
+ <%= render_output(output) %>
+
+ <% end %>
+
+ """
+ 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"""
+
+ """
+ end
+
+ defp cell_type_icon(:markdown) do
+ ~e"""
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/icons.ex b/lib/live_book_web/live/icons.ex
new file mode 100644
index 000000000..320b79869
--- /dev/null
+++ b/lib/live_book_web/live/icons.ex
@@ -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"""
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:play, attrs) do
+ ~e"""
+
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:plus, attrs) do
+ ~e"""
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:trash, attrs) do
+ ~e"""
+
+ """
+ |> 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
diff --git a/lib/live_book_web/live/insert_cell_actions.ex b/lib/live_book_web/live/insert_cell_actions.ex
new file mode 100644
index 000000000..71b91d392
--- /dev/null
+++ b/lib/live_book_web/live/insert_cell_actions.ex
@@ -0,0 +1,34 @@
+defmodule LiveBookWeb.InsertCellActions do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+
+ <%= line() %>
+
+
+ <%= line() %>
+
+ """
+ end
+
+ defp line() do
+ ~e"""
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/section.ex b/lib/live_book_web/live/section.ex
new file mode 100644
index 000000000..337db6822
--- /dev/null
+++ b/lib/live_book_web/live/section.ex
@@ -0,0 +1,30 @@
+defmodule LiveBookWeb.Section do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+ ">
+
+
+ <%= Icons.svg(:chevron_right, class: "h-8") %>
+
<%= @section.name %>
+
+
+
+
+
+
+
+ <%= 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 %>
+
+
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex
index 99a4b2b70..8e351e1d7 100644
--- a/lib/live_book_web/live/session_live.ex
+++ b/lib/live_book_web/live/session_live.ex
@@ -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"""
-
- Session <%= @session_id %>
+
+
+
<%= @data.notebook.name %>
+
+ <%= for section <- @data.notebook.sections do %>
+
+
+ <%= section.name %>
+
+
+ <% end %>
+
+
+ <%= Icons.svg(:plus, class: "h-6") %>
+ New section
+
+
+
+
+
+
+ <%= 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 %>
+
+
"""
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
diff --git a/lib/live_book_web/live/sessions_live.ex b/lib/live_book_web/live/sessions_live.ex
index d535203e0..77e15eb55 100644
--- a/lib/live_book_web/live/sessions_live.ex
+++ b/lib/live_book_web/live/sessions_live.ex
@@ -22,14 +22,12 @@ defmodule LiveBookWeb.SessionsLive do
<%= for session_id <- Enum.sort(@session_ids) do %>
-
+
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
-
diff --git a/lib/live_book_web/templates/layout/live.html.leex b/lib/live_book_web/templates/layout/live.html.leex
index 31d4dc7dc..33922bd71 100644
--- a/lib/live_book_web/templates/layout/live.html.leex
+++ b/lib/live_book_web/templates/layout/live.html.leex
@@ -1,24 +1,26 @@
-
+<%#
%>
+
+
+
+
<%= live_flash(@flash, :info) %>
-
-
-
<%= live_flash(@flash, :info) %>
+
<%= live_flash(@flash, :error) %>
+
- <%= live_flash(@flash, :error) %>
-
-
- <%= @inner_content %>
-
+ <%= @inner_content %>
+
+<%#
%>