diff --git a/README.md b/README.md index e6577daac..b1bb6d46c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

Livebook

+

Livebook

Livebook is a web application for writing interactive and collaborative code notebooks. It features: diff --git a/lib/livebook/notebook/explore.ex b/lib/livebook/notebook/explore.ex new file mode 100644 index 000000000..befde2ee9 --- /dev/null +++ b/lib/livebook/notebook/explore.ex @@ -0,0 +1,114 @@ +defmodule Livebook.Notebook.Explore.Utils do + @moduledoc false + + @doc """ + Defines a module attribute `attr` with notebook info. + """ + defmacro defnotebook(attr, props) do + quote bind_quoted: [attr: attr, props: props] do + {path, notebook_info} = Livebook.Notebook.Explore.Utils.fetch_notebook!(attr, props) + + @external_resource path + + Module.put_attribute(__MODULE__, attr, notebook_info) + end + end + + def fetch_notebook!(attr, props) do + name = Atom.to_string(attr) + path = Path.join([__DIR__, "explore", name <> ".livemd"]) + + markdown = File.read!(path) + # Parse the file to ensure no warnings and read the title. + # However, in the info we keep just the file contents to save on memory. + {notebook, []} = Livebook.LiveMarkdown.Import.notebook_from_markdown(markdown) + + notebook_info = %{ + slug: String.replace(name, "_", "-"), + livemd: markdown, + title: notebook.name, + description: Keyword.fetch!(props, :description), + image_url: Keyword.fetch!(props, :image_url) + } + + {path, notebook_info} + end +end + +defmodule Livebook.Notebook.Explore do + @moduledoc false + + defmodule NotFoundError do + @moduledoc false + + defexception [:slug, plug_status: 404] + + def message(%{slug: slug}) do + "could not find an example notebook matching #{inspect(slug)}" + end + end + + import Livebook.Notebook.Explore.Utils + + defnotebook(:intro_to_livebook, + description: "Get to know Livebook, see how it works and explore its features.", + image_url: "/images/logo.png" + ) + + defnotebook(:intro_to_elixir, + description: "New to Elixir? Learn about the language and its core concepts.", + image_url: "/images/elixir.png" + ) + + defnotebook(:intro_to_nx, + description: + "Enter numerical Elixir, experience the power of multi-dimensional arrays of numbers.", + image_url: "/images/nx.png" + ) + + defnotebook(:intro_to_axon, + description: "Build Neural Networks in Elixir using a high-level, composable API.", + image_url: "/images/axon.png" + ) + + defnotebook(:intro_to_vega_lite, + description: "Learn how to quickly create numerous plots for your data.", + image_url: "/images/vega_lite.png" + ) + + @type notebook_info :: %{ + slug: String.t(), + livemd: String.t(), + title: String.t(), + description: String.t(), + image_url: String.t() + } + + @doc """ + Returns a list of example notebooks with metadata. + """ + @spec notebook_infos() :: list(notebook_info()) + def notebook_infos() do + [ + @intro_to_livebook + # @intro_to_elixir, @intro_to_nx, @intro_to_axon, @intro_to_vega_lite + ] + end + + @doc """ + Finds explore notebook by slug and returns the parsed data structure. + """ + @spec notebook_by_slug!(String.t()) :: Livebook.Notebook.t() + def notebook_by_slug!(slug) do + notebook_infos() + |> Enum.find(&(&1.slug == slug)) + |> case do + nil -> + raise NotFoundError, slug: slug + + notebook_info -> + {notebook, []} = Livebook.LiveMarkdown.Import.notebook_from_markdown(notebook_info.livemd) + notebook + end + end +end diff --git a/lib/livebook/notebook/explore/intro_to_axon.livemd b/lib/livebook/notebook/explore/intro_to_axon.livemd new file mode 100644 index 000000000..24ced0adf --- /dev/null +++ b/lib/livebook/notebook/explore/intro_to_axon.livemd @@ -0,0 +1,3 @@ +# Neural Networks with Axon + +TODO: content 🐈 diff --git a/lib/livebook/notebook/explore/intro_to_elixir.livemd b/lib/livebook/notebook/explore/intro_to_elixir.livemd new file mode 100644 index 000000000..0db126e46 --- /dev/null +++ b/lib/livebook/notebook/explore/intro_to_elixir.livemd @@ -0,0 +1,3 @@ +# Introduction to Elixir + +TODO: content 🐈 diff --git a/lib/livebook/notebook/explore/intro_to_livebook.livemd b/lib/livebook/notebook/explore/intro_to_livebook.livemd new file mode 100644 index 000000000..f029ba2e9 --- /dev/null +++ b/lib/livebook/notebook/explore/intro_to_livebook.livemd @@ -0,0 +1,213 @@ +# Welcome to Livebook + +## Introduction + +We are happy you decided to give Livebook a try, hopefully it empowers +you to build great stuff! 🚀 + +Livebook is a tool for crafting **interactive** and **collaborative** code notebooks. +It is primarily meant as a tool for rapid prototyping - think of it as an IEx session +combined with your editor. +You can also use it for authoring shareable articles that people can easily +run and play around with. +Package authors can write notebooks as interactive tutorials +and include them in their repository, so that users can easily download +and run them locally. + +## Basic usage + +Each notebook consists of a number of cells, which serve as primary building blocks. +There are **Markdown** cells (such as this one) that allow you to describe your work +and **Elixir** cells where the magic takes place! + +To insert a new cell move your cursor between cells and click one of the revealed buttons. 👇 + +```elixir +# This is an Elixir cell - as the name suggests that's where the code goes. +# To evaluate this cell, you can either press the "Evaluate" button above +# or use `Ctrl + Enter` (or Cmd + Enter on a Mac)! + +message = "hey, grab yourself a cup of 🍵" +``` + +Subsequent cells have access to the bindings you've defined: + +```elixir +String.replace(message, "🍵", "☕") +``` + +Note however that bindings are not global, so each cell *sees* only stuff that goes +above itself. This approach helps to keep the notebook clean and predictable +as you keep working on it! + +## Sections + +You can leverage so called **sections** to nicely group related cells together. +Click on the "Book" icon in the sidebar to reveal a list of all sections. +As you can see, this approach helps to easily jump around the notebook, +especially once it grows. + +Let's make use of this section to see how output is captured! + +```elixir +cats = ~w(😼 😹 😻 😺 😸 😽) + +for _ <- 1..3 do + cats + |> Enum.take_random(3) + |> Enum.join(" ") + |> IO.puts() +end +``` + +## Notebook files + +By default notebooks are kept in memory, which is fine for interactive hacking, +but oftentimes you will want to save your work for later. Fortunately, notebooks +can be persisted by clicking on the "Disk" icon in the bottom-right corner +and selecting the file location. + +Notebooks are stored in **live markdown** format, which is essentially the markdown you know, +with just a few assumptions on how particular elements are represented. Thanks to this +approach you can easily keep notebooks under version control and get readable diffs. +You can also easily preview those files, reuse for blog posts, and even edit in a text editor. + +## Modules + +As we have seen, Elixir cells can be used for working on tiny snippets, +but you may as well define a module! + +```elixir +defmodule Utils do + @doc """ + Generates a random binary id. + """ + @spec random_id() :: binary() + def random_id() do + :crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower) + end +end +``` + +If you're surprised by the above output, keep in mind that +every Elixir expression evaluates to some value and as so does module compilation! + +Having the module defined, let's take it for a spin. + +```elixir +Utils.random_id() +``` + +## Imports + +You can import modules as normally to make the imported functions visible +to all subsequent cells. Usually you want to keep `import`, `alias` and `require` +in the first section, as part of the notebook setup. + +```elixir +import IEx.Helpers +``` + +```elixir +h(Enum.map()) +``` + +```elixir +# Sidenote: http://www.numbat.org.au/thenumbat +i("I ❤️ Numbats") +``` + +## Runtimes + +Livebook has a concept of **runtime**, which in practice is an Elixir node responsible +for evaluating your code. + +By default, a new Elixir node is started (similarly to starting `iex`), +but you can also choose to run inside a Mix project (as you would with `iex -S mix`) +or even manually attach to an existing distributed node! +You can configure the runtime by clicking the "Runtime" icon on the sidebar. + +## Using packages + +Sometimes you need a dependency or two and notebooks are no exception to this. + +One way to work with packages is to create a Mix project and configure the notebook +to run in its context (as pointed out above). This approach makes sense if you already have +a Mix project that you are working on, especially because this makes all project's +modules available as well. + +But there are cases when you just want to play around with a new package +or quickly prototype some code that relies on such. Fortunately, Elixir v1.12+ ships with +[`Mix.install/2`](https://hexdocs.pm/mix/Mix.html#install/2) that allows you to install +dependencies into your Elixir runtime! This approach is especially useful when sharing notebooks +because everyone will be able to get the same dependencies. Let's try this out: + +**Note:** compiling dependencies may use a reasonable amount of memory. If you are +hosting Livebook, make sure you have enough memory allocated to the Livebook +instance, otherwise the command below will fail. + +```elixir +Mix.install([ + {:jason, "~> 1.2"} +]) +``` + +```elixir +%{elixir: "is awesome"} +|> Jason.encode!() +|> IO.puts() +``` + +It is a good idea to specify versions of the installed packages, +so that the notebook is easily reproducible later on. + +Also keep in mind that `Mix.install/2` can be called only once +per runtime, so if you need to modify the dependencies, you should +go to the notebook runtime configuration and **reconnect** the current runtime. + +## Running tests + +It is also possible to run tests directly from your notebooks. +The key is to disable `ExUnit`'s autorun feature and then explicitly +run the test suite after all test cases have been defined: + +```elixir +ExUnit.start(autorun: false) + +defmodule MyTest do + use ExUnit.Case, async: true + + test "it works" do + assert true + end +end + +ExUnit.run() +``` + +## Math + +Livebook uses $\\TeX$ syntax for math. +It supports both inline math like $e^{\\pi i} + 1 = 0$, as well as display math: + +$$ +S(x) = \\frac{1}{1 + e^{-x}} = \\frac{e^{x}}{e^{x} + 1} +$$ + +You can explore all supported expressions [here](https://katex.org/docs/supported.html). + +## Stepping up your workflow + +Once you start using notebooks more, it's gonna be beneficial +to optimise how you move around. Livebook leverages the concept of +**navigation**/**insert** modes and offers many shortcuts for common operations. +Make sure to check out the shortcuts by clicking the "Keyboard" icon in +the sidebar or by typing `?`. + +## Final notes + +Livebook is an open source project, so feel free to look into +[the repository](https://github.com/elixir-nx/livebook) +to contribute, report bugs, suggest features or just skim over the codebase. + +Now go ahead and build something cool! 🚢 diff --git a/lib/livebook/notebook/explore/intro_to_nx.livemd b/lib/livebook/notebook/explore/intro_to_nx.livemd new file mode 100644 index 000000000..fcbec5078 --- /dev/null +++ b/lib/livebook/notebook/explore/intro_to_nx.livemd @@ -0,0 +1,3 @@ +# Introduction to Nx + + TODO: content 🐈 diff --git a/lib/livebook/notebook/explore/intro_to_vega_lite.livemd b/lib/livebook/notebook/explore/intro_to_vega_lite.livemd new file mode 100644 index 000000000..587e7cad6 --- /dev/null +++ b/lib/livebook/notebook/explore/intro_to_vega_lite.livemd @@ -0,0 +1,3 @@ +# Plotting with VegaLite and Kino + +TODO: content 🐈 diff --git a/lib/livebook/notebook/welcome.ex b/lib/livebook/notebook/welcome.ex deleted file mode 100644 index 16c789c11..000000000 --- a/lib/livebook/notebook/welcome.ex +++ /dev/null @@ -1,225 +0,0 @@ -defmodule Livebook.Notebook.Welcome do - livemd = ~s''' - # Welcome to Livebook - - ## Introduction - - We are happy you decided to give Livebook a try, hopefully it empowers - you to build great stuff! 🚀 - - Livebook is a tool for crafting **interactive** and **collaborative** code notebooks. - It is primarily meant as a tool for rapid prototyping - think of it as an IEx session - combined with your editor. - You can also use it for authoring shareable articles that people can easily - run and play around with. - Package authors can write notebooks as interactive tutorials - and include them in their repository, so that users can easily download - and run them locally. - - ## Basic usage - - Each notebook consists of a number of cells, which serve as primary building blocks. - There are **Markdown** cells (such as this one) that allow you to describe your work - and **Elixir** cells where the magic takes place! - - To insert a new cell move your cursor between cells and click one of the revealed buttons. 👇 - - ```elixir - # This is an Elixir cell - as the name suggests that's where the code goes. - # To evaluate this cell, you can either press the "Evaluate" button above - # or use `Ctrl + Enter` (or Cmd + Enter on a Mac)! - - message = "hey, grab yourself a cup of 🍵" - ``` - - Subsequent cells have access to the bindings you've defined: - - ```elixir - String.replace(message, "🍵", "☕") - ``` - - Note however that bindings are not global, so each cell *sees* only stuff that goes - above itself. This approach helps to keep the notebook clean and predictable - as you keep working on it! - - ## Sections - - You can leverage so called **sections** to nicely group related cells together. - Click on the "Book" icon in the sidebar to reveal a list of all sections. - As you can see, this approach helps to easily jump around the notebook, - especially once it grows. - - Let's make use of this section to see how output is captured! - - ```elixir - cats = ~w(😼 😹 😻 😺 😸 😽) - - for _ <- 1..3 do - cats - |> Enum.take_random(3) - |> Enum.join(" ") - |> IO.puts() - end - ``` - - ## Notebook files - - By default notebooks are kept in memory, which is fine for interactive hacking, - but oftentimes you will want to save your work for later. Fortunately, notebooks - can be persisted by clicking on the "Disk" icon in the bottom-right corner - and selecting the file location. - - Notebooks are stored in **live markdown** format, which is essentially the markdown you know, - with just a few assumptions on how particular elements are represented. Thanks to this - approach you can easily keep notebooks under version control and get readable diffs. - You can also easily preview those files, reuse for blog posts, and even edit in a text editor. - - ## Modules - - As we have seen, Elixir cells can be used for working on tiny snippets, - but you may as well define a module! - - ```elixir - defmodule Utils do - @doc """ - Generates a random binary id. - """ - @spec random_id() :: binary() - def random_id() do - :crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower) - end - end - ``` - - If you're surprised by the above output, keep in mind that - every Elixir expression evaluates to some value and as so does module compilation! - - Having the module defined, let's take it for a spin. - - ```elixir - Utils.random_id() - ``` - - ## Imports - - You can import modules as normally to make the imported functions visible - to all subsequent cells. Usually you want to keep `import`, `alias` and `require` - in the first section, as part of the notebook setup. - - ```elixir - import IEx.Helpers - ``` - - ```elixir - h(Enum.map()) - ``` - - ```elixir - # Sidenote: http://www.numbat.org.au/thenumbat - i("I ❤️ Numbats") - ``` - - ## Runtimes - - Livebook has a concept of **runtime**, which in practice is an Elixir node responsible - for evaluating your code. - - By default, a new Elixir node is started (similarly to starting `iex`), - but you can also choose to run inside a Mix project (as you would with `iex -S mix`) - or even manually attach to an existing distributed node! - You can configure the runtime by clicking the "Runtime" icon on the sidebar. - - ## Using packages - - Sometimes you need a dependency or two and notebooks are no exception to this. - - One way to work with packages is to create a Mix project and configure the notebook - to run in its context (as pointed out above). This approach makes sense if you already have - a Mix project that you are working on, especially because this makes all project's - modules available as well. - - But there are cases when you just want to play around with a new package - or quickly prototype some code that relies on such. Fortunately, Elixir v1.12+ ships with - [`Mix.install/2`](https://hexdocs.pm/mix/Mix.html#install/2) that allows you to install - dependencies into your Elixir runtime! This approach is especially useful when sharing notebooks - because everyone will be able to get the same dependencies. Let's try this out: - - **Note:** compiling dependencies may use a reasonable amount of memory. If you are - hosting Livebook, make sure you have enough memory allocated to the Livebook - instance, otherwise the command below will fail. - - ```elixir - Mix.install([ - {:jason, "~> 1.2"} - ]) - ``` - - ```elixir - %{elixir: "is awesome"} - |> Jason.encode!() - |> IO.puts() - ``` - - It is a good idea to specify versions of the installed packages, - so that the notebook is easily reproducible later on. - - Also keep in mind that `Mix.install/2` can be called only once - per runtime, so if you need to modify the dependencies, you should - go to the notebook runtime configuration and **reconnect** the current runtime. - - ## Running tests - - It is also possible to run tests directly from your notebooks. - The key is to disable `ExUnit`'s autorun feature and then explicitly - run the test suite after all test cases have been defined: - - ```elixir - ExUnit.start(autorun: false) - - defmodule MyTest do - use ExUnit.Case, async: true - - test "it works" do - assert true - end - end - - ExUnit.run() - ``` - - ## Math - - Livebook uses $\TeX$ syntax for math. - It supports both inline math like $e^{\\pi i} + 1 = 0$, as well as display math: - - $$ - S(x) = \\frac{1}{1 + e^{-x}} = \\frac{e^{x}}{e^{x} + 1} - $$ - - You can explore all supported expressions [here](https://katex.org/docs/supported.html). - - ## Stepping up your workflow - - Once you start using notebooks more, it's gonna be beneficial - to optimise how you move around. Livebook leverages the concept of - **navigation**/**insert** modes and offers many shortcuts for common operations. - Make sure to check out the shortcuts by clicking the "Keyboard" icon in - the sidebar or by typing `?`. - - ## Final notes - - Livebook is an open source project, so feel free to look into - [the repository](https://github.com/elixir-nx/livebook) - to contribute, report bugs, suggest features or just skim over the codebase. - - Now go ahead and build something cool! 🚢 - ''' - - {notebook, []} = Livebook.LiveMarkdown.Import.notebook_from_markdown(livemd) - - @notebook notebook - - def new() do - @notebook - end -end diff --git a/lib/livebook_web/live/explore_live.ex b/lib/livebook_web/live/explore_live.ex new file mode 100644 index 000000000..4826e97a7 --- /dev/null +++ b/lib/livebook_web/live/explore_live.ex @@ -0,0 +1,108 @@ +defmodule LivebookWeb.ExploreLive do + use LivebookWeb, :live_view + + import LivebookWeb.UserHelpers + import LivebookWeb.SessionHelpers + + alias Livebook.Notebook.Explore + + @impl true + def mount(_params, %{"current_user_id" => current_user_id}, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}") + end + + current_user = build_current_user(current_user_id, socket) + + [lead_notebook_info | notebook_infos] = Explore.notebook_infos() + + {:ok, + assign(socket, + current_user: current_user, + lead_notebook_info: lead_notebook_info, + notebook_infos: notebook_infos + )} + end + + @impl true + def render(assigns) do + ~L""" +
+ <%= live_component @socket, LivebookWeb.SidebarComponent, + id: :sidebar, + items: [ + %{type: :logo}, + %{type: :break}, + %{type: :user, current_user: @current_user, path: Routes.explore_path(@socket, :user)} + ] %> +
+
+
+
+ <%= live_patch to: Routes.home_path(@socket, :page), + class: "hidden md:block absolute top-[50%] left-[-12px] transform -translate-y-1/2 -translate-x-full" do %> + <%= remix_icon("arrow-left-line", class: "text-2xl align-middle") %> + <% end %> +

+ Explore +

+
+

+ Check out a number of examples showcasing various parts of the Elixir ecosystem. + Click on any notebook you like and start playing around with it! +

+
+
+
+

+ <%= @lead_notebook_info.title %> +

+

+ <%= @lead_notebook_info.description %> +

+
+ <%= live_patch "Let's go", to: Routes.explore_path(@socket, :notebook, @lead_notebook_info.slug), + class: "button button-blue" %> +
+
+ +
+
+ <%= for {info, idx} <- Enum.with_index(@notebook_infos) do %> + <%= live_component @socket, LivebookWeb.NotebookCardComponent, + id: "notebook-card-#{idx}", + notebook_info: info %> + <% end %> +
+
+
+
+ + <%= if @live_action == :user do %> + <%= live_modal @socket, LivebookWeb.UserComponent, + id: :user_modal, + modal_class: "w-full max-w-sm", + user: @current_user, + return_to: Routes.explore_path(@socket, :page) %> + <% end %> + """ + end + + @impl true + def handle_params(%{"slug" => slug}, _url, socket) do + notebook = Explore.notebook_by_slug!(slug) + {:noreply, create_session(socket, notebook: notebook)} + end + + def handle_params(_params, _url, socket), do: {:noreply, socket} + + @impl true + def handle_info( + {:user_change, %{id: id} = user}, + %{assigns: %{current_user: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :current_user, user)} + end +end diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 2ee20d506..660b422c0 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -2,6 +2,7 @@ defmodule LivebookWeb.HomeLive do use LivebookWeb, :live_view import LivebookWeb.UserHelpers + import LivebookWeb.SessionHelpers alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook} @@ -14,12 +15,14 @@ defmodule LivebookWeb.HomeLive do current_user = build_current_user(current_user_id, socket) session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries()) + notebook_infos = Notebook.Explore.notebook_infos() |> Enum.take(3) {:ok, assign(socket, current_user: current_user, path: default_path(), - session_summaries: session_summaries + session_summaries: session_summaries, + notebook_infos: notebook_infos )} end @@ -27,28 +30,19 @@ defmodule LivebookWeb.HomeLive do def render(assigns) do ~L"""
-
-
- - <%= live_patch to: Routes.home_path(@socket, :user), - class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %> - <%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %> - <% end %> - -
+ <%= live_component @socket, LivebookWeb.SidebarComponent, + id: :sidebar, + items: [ + %{type: :break}, + %{type: :user, current_user: @current_user, path: Routes.home_path(@socket, :user)} + ] %>
-
-
+
+
- Livebook + Livebook
- - - <%= live_patch to: Routes.home_path(@socket, :import, "url"), class: "button button-outlined-gray whitespace-nowrap" do %> Import @@ -59,7 +53,7 @@ defmodule LivebookWeb.HomeLive do
-
+
<%= live_component @socket, LivebookWeb.PathSelectComponent, id: "path_select", path: @path, @@ -92,10 +86,29 @@ defmodule LivebookWeb.HomeLive do
<% end %>
-
-

+
+
+

+ Explore +

+ <%= live_redirect to: Routes.explore_path(@socket, :page), + class: "flex items-center text-blue-600" do %> + See all + <%= remix_icon("arrow-right-line", class: "align-middle ml-1") %> + <% end %> +
+
+ <%= for {info, idx} <- Enum.with_index(@notebook_infos) do %> + <%= live_component @socket, LivebookWeb.NotebookCardComponent, + id: "notebook-card-#{idx}", + notebook_info: info %> + <% end %> +
+
+
+

Running sessions -

+

<%= if @session_summaries == [] do %>
@@ -160,12 +173,8 @@ defmodule LivebookWeb.HomeLive do {:noreply, assign(socket, path: path)} end - def handle_event("open_welcome", %{}, socket) do - create_session(socket, notebook: Livebook.Notebook.Welcome.new()) - end - def handle_event("new", %{}, socket) do - create_session(socket) + {:noreply, create_session(socket)} end def handle_event("fork", %{}, socket) do @@ -173,20 +182,20 @@ defmodule LivebookWeb.HomeLive do socket = put_import_flash_messages(socket, messages) notebook = Notebook.forked(notebook) images_dir = Session.images_dir_for_notebook(socket.assigns.path) - create_session(socket, notebook: notebook, copy_images_from: images_dir) + {:noreply, create_session(socket, notebook: notebook, copy_images_from: images_dir)} end def handle_event("open", %{}, socket) do {notebook, messages} = import_notebook(socket.assigns.path) socket = put_import_flash_messages(socket, messages) - create_session(socket, notebook: notebook, path: socket.assigns.path) + {:noreply, create_session(socket, notebook: notebook, path: socket.assigns.path)} end def handle_event("fork_session", %{"id" => session_id}, socket) do data = Session.get_data(session_id) notebook = Notebook.forked(data.notebook) %{images_dir: images_dir} = Session.get_summary(session_id) - create_session(socket, notebook: notebook, copy_images_from: images_dir) + {:noreply, create_session(socket, notebook: notebook, copy_images_from: images_dir)} end @impl true @@ -204,7 +213,7 @@ defmodule LivebookWeb.HomeLive do def handle_info({:import_content, content}, socket) do {notebook, messages} = Livebook.LiveMarkdown.Import.notebook_from_markdown(content) socket = put_import_flash_messages(socket, messages) - create_session(socket, notebook: notebook) + {:noreply, create_session(socket, notebook: notebook)} end def handle_info( @@ -246,16 +255,6 @@ defmodule LivebookWeb.HomeLive do end end - defp create_session(socket, opts \\ []) do - case SessionSupervisor.create_session(opts) do - {:ok, id} -> - {:noreply, push_redirect(socket, to: Routes.session_path(socket, :page, id))} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to create a notebook: #{reason}")} - end - end - defp import_notebook(path) do content = File.read!(path) LiveMarkdown.Import.notebook_from_markdown(content) diff --git a/lib/livebook_web/live/notebook_card_component.ex b/lib/livebook_web/live/notebook_card_component.ex new file mode 100644 index 000000000..da85d0333 --- /dev/null +++ b/lib/livebook_web/live/notebook_card_component.ex @@ -0,0 +1,22 @@ +defmodule LivebookWeb.NotebookCardComponent do + use LivebookWeb, :live_component + + @impl true + def render(assigns) do + ~L""" +
+
+ +
+
+ <%= live_redirect @notebook_info.title, + to: Routes.explore_path(@socket, :notebook, @notebook_info.slug), + class: "text-gray-800 font-semibold cursor-pointer" %> +

+ <%= @notebook_info.description %> +

+
+
+ """ + end +end diff --git a/lib/livebook_web/live/session_helpers.ex b/lib/livebook_web/live/session_helpers.ex new file mode 100644 index 000000000..4a16b8d3c --- /dev/null +++ b/lib/livebook_web/live/session_helpers.ex @@ -0,0 +1,21 @@ +defmodule LivebookWeb.SessionHelpers do + import Phoenix.LiveView + alias LivebookWeb.Router.Helpers, as: Routes + + @doc """ + Creates a new session, redirects on success, + puts an error flash message on failure. + + Accepts the same options as `Livebook.SessionSupervisor.create_session/1`. + """ + @spec create_session(Phoenix.LiveView.Socket.t(), keyword()) :: Phoenix.LiveView.Socket.t() + def create_session(socket, opts \\ []) do + case Livebook.SessionSupervisor.create_session(opts) do + {:ok, id} -> + push_redirect(socket, to: Routes.session_path(socket, :page, id)) + + {:error, reason} -> + put_flash(socket, :error, "Failed to create session: #{reason}") + end + end +end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 44f2d7b6a..c32fbc68f 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -71,40 +71,41 @@ defmodule LivebookWeb.SessionLive do id="session" data-element="session" phx-hook="Session"> -
- <%= live_patch to: Routes.home_path(@socket, :page) do %> - livebook - <% end %> - - - - - - - - <%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id), - class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :runtime_settings, do: "text-gray-50 bg-gray-700")}" do %> - <%= remix_icon("cpu-line", class: "text-2xl") %> - <% end %> - -
- - <%= live_patch to: Routes.session_path(@socket, :shortcuts, @session_id), - class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :shortcuts, do: "text-gray-50 bg-gray-700")}" do %> - <%= remix_icon("keyboard-box-fill", class: "text-2xl") %> - <% end %> - - - <%= live_patch to: Routes.session_path(@socket, :user, @session_id), - class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %> - <%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %> - <% end %> - -
+ <%= live_component @socket, LivebookWeb.SidebarComponent, + id: :sidebar, + items: [ + %{type: :logo}, + %{ + type: :button, + data_element: "sections-list-toggle", + icon: "booklet-fill", + label: "Sections (ss)", + active: false + }, + %{ + type: :button, + data_element: "clients-list-toggle", + icon: "group-fill", + label: "Connected users (su)", + active: false + }, + %{ + type: :link, + icon: "cpu-line", + path: Routes.session_path(@socket, :runtime_settings, @session_id), + label: "Runtime settings (sr)", + active: @live_action == :runtime_settings + }, + %{type: :break}, + %{ + type: :link, + icon: "keyboard-box-fill", + path: Routes.session_path(@socket, :shortcuts, @session_id), + label: "Keyboard shortcuts (?)", + active: @live_action == :shortcuts + }, + %{type: :user, current_user: @current_user, path: Routes.session_path(@socket, :user, @session_id)} + ] %>
diff --git a/lib/livebook_web/live/sidebar_component.ex b/lib/livebook_web/live/sidebar_component.ex new file mode 100644 index 000000000..2df81ff6b --- /dev/null +++ b/lib/livebook_web/live/sidebar_component.ex @@ -0,0 +1,77 @@ +defmodule LivebookWeb.SidebarComponent do + use LivebookWeb, :live_component + + import LivebookWeb.UserHelpers + + # ## Attributes + # + # * `:items` - a list of sidebar items + + @impl true + def render(assigns) do + ~L""" +
+ <%= for item <- @items do %> + <%= render_item(@socket, item) %> + <% end %> +
+ """ + end + + defp render_item(socket, %{type: :logo} = item) do + assigns = %{item: item} + + ~L""" + <%= live_patch to: Routes.home_path(socket, :page) do %> + livebook + <% end %> + """ + end + + defp render_item(_socket, %{type: :button} = item) do + assigns = %{item: item} + + ~L""" + + + + """ + end + + defp render_item(_socket, %{type: :link} = item) do + assigns = %{item: item} + + ~L""" + + <%= live_patch to: item.path, + class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(item.active, do: "text-gray-50 bg-gray-700")}" do %> + <%= remix_icon(item.icon, class: "text-2xl") %> + <% end %> + + """ + end + + defp render_item(_socket, %{type: :break}) do + assigns = %{} + + ~L""" +
+ """ + end + + defp render_item(_socket, %{type: :user} = item) do + assigns = %{item: item} + + ~L""" + + <%= live_patch to: item.path, + class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %> + <%= render_user_avatar(item.current_user, class: "h-full w-full", text_class: "text-xs") %> + <% end %> + + """ + end +end diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index 46c8e9644..e84344c7c 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -23,6 +23,9 @@ defmodule LivebookWeb.Router do live "/home/user-profile", HomeLive, :user live "/home/import/:tab", HomeLive, :import live "/home/sessions/:session_id/close", HomeLive, :close_session + live "/explore", ExploreLive, :page + live "/explore/user-profile", ExploreLive, :user + live "/explore/notebooks/:slug", ExploreLive, :notebook live "/sessions/:id", SessionLive, :page live "/sessions/:id/user-profile", SessionLive, :user live "/sessions/:id/shortcuts", SessionLive, :shortcuts diff --git a/lib/livebook_web/templates/auth/index.html.eex b/lib/livebook_web/templates/auth/index.html.eex index c9de1a690..859d897eb 100644 --- a/lib/livebook_web/templates/auth/index.html.eex +++ b/lib/livebook_web/templates/auth/index.html.eex @@ -1,7 +1,7 @@
- livebook + livebook
Authentication required diff --git a/lib/livebook_web/templates/error/401.html.eex b/lib/livebook_web/templates/error/401.html.eex index 742b0c434..259eed4d7 100644 --- a/lib/livebook_web/templates/error/401.html.eex +++ b/lib/livebook_web/templates/error/401.html.eex @@ -11,7 +11,7 @@
- livebook + livebook
Authentication required diff --git a/lib/livebook_web/templates/error/404.html.eex b/lib/livebook_web/templates/error/404.html.eex index c42743caa..eefa9d721 100644 --- a/lib/livebook_web/templates/error/404.html.eex +++ b/lib/livebook_web/templates/error/404.html.eex @@ -11,7 +11,7 @@
- livebook + livebook
No Numbats here! diff --git a/lib/livebook_web/templates/error/500.html.eex b/lib/livebook_web/templates/error/500.html.eex index 125c453dd..1fe0c6207 100644 --- a/lib/livebook_web/templates/error/500.html.eex +++ b/lib/livebook_web/templates/error/500.html.eex @@ -11,7 +11,7 @@
- livebook + livebook
Something went wrong. diff --git a/priv/static/images/axon.png b/priv/static/images/axon.png new file mode 100644 index 000000000..da551dae8 Binary files /dev/null and b/priv/static/images/axon.png differ diff --git a/priv/static/images/elixir.png b/priv/static/images/elixir.png new file mode 100644 index 000000000..74ba33263 Binary files /dev/null and b/priv/static/images/elixir.png differ diff --git a/priv/static/logo-with-text.png b/priv/static/images/logo-with-text.png similarity index 100% rename from priv/static/logo-with-text.png rename to priv/static/images/logo-with-text.png diff --git a/priv/static/logo.png b/priv/static/images/logo.png similarity index 100% rename from priv/static/logo.png rename to priv/static/images/logo.png diff --git a/priv/static/images/nx.png b/priv/static/images/nx.png new file mode 100644 index 000000000..26eb95575 Binary files /dev/null and b/priv/static/images/nx.png differ diff --git a/priv/static/images/vega_lite.png b/priv/static/images/vega_lite.png new file mode 100644 index 000000000..82fa28693 Binary files /dev/null and b/priv/static/images/vega_lite.png differ diff --git a/test/livebook/explore_test.exs b/test/livebook/explore_test.exs new file mode 100644 index 000000000..23a4a57a0 --- /dev/null +++ b/test/livebook/explore_test.exs @@ -0,0 +1,18 @@ +defmodule Livebook.ExploreTest do + use ExUnit.Case, async: true + + alias Livebook.Notebook + alias Livebook.Notebook.Explore + + describe "notebook_by_slug!/1" do + test "returns notebook structure if found" do + assert %Notebook{} = Explore.notebook_by_slug!("intro-to-livebook") + end + + test "raises an error if no matching notebook if found" do + assert_raise Explore.NotFoundError, fn -> + Explore.notebook_by_slug!("invalid-slug") + end + end + end +end diff --git a/test/livebook/notebook/welcome_test.exs b/test/livebook/notebook/welcome_test.exs deleted file mode 100644 index 1d9191789..000000000 --- a/test/livebook/notebook/welcome_test.exs +++ /dev/null @@ -1,10 +0,0 @@ -defmodule Livebook.Notebook.WelcomeTest do - use ExUnit.Case, async: true - - alias Livebook.Notebook - alias Livebook.Notebook.Welcome - - test "new/0 correctly builds a new notebook" do - assert %Notebook{} = Welcome.new() - end -end diff --git a/test/livebook_web/live/explore_live_test.exs b/test/livebook_web/live/explore_live_test.exs new file mode 100644 index 000000000..200bea570 --- /dev/null +++ b/test/livebook_web/live/explore_live_test.exs @@ -0,0 +1,19 @@ +defmodule LivebookWeb.ExploreLiveTest do + use LivebookWeb.ConnCase + + import Phoenix.LiveViewTest + + test "link to introductory notebook correctly creates a new session", %{conn: conn} do + {:ok, view, _} = live(conn, "/explore") + + assert {:error, {:live_redirect, %{to: to}}} = + view + |> element(~s{a}, "Let's go") + |> render_click() + + assert to =~ "/sessions/" + + {:ok, view, _} = live(conn, to) + assert render(view) =~ "Welcome to Livebook" + end +end diff --git a/test/livebook_web/live/home_live_test.exs b/test/livebook_web/live/home_live_test.exs index b1b07510b..1e6207f45 100644 --- a/test/livebook_web/live/home_live_test.exs +++ b/test/livebook_web/live/home_live_test.exs @@ -163,8 +163,9 @@ defmodule LivebookWeb.HomeLiveTest do assert {:error, {:live_redirect, %{to: to}}} = view - |> element(~s{[aria-label="Introduction"] button}) + |> element(~s{a}, "Welcome to Livebook") |> render_click() + |> follow_redirect(conn) assert to =~ "/sessions/"