Introduce an Explore section (#310)

* Add explore page

* Move sidebar to a configurable component

* Fix homepage test

* Add images

* Store example notebooks in files and make explore notebooks linkable

* Fix tests

* Raise on invalid notebook slug

* Keep just the file contents in notebook info

* Move notebook lookup to Explore

* Exclude notebooks in progress
This commit is contained in:
Jonatan Kłosko 2021-06-02 21:51:43 +02:00 committed by GitHub
parent 69890cf43e
commit f70581f255
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 690 additions and 317 deletions

View file

@ -1,4 +1,4 @@
<h1><img src="https://github.com/elixir-nx/livebook/raw/main/priv/static/logo-with-text.png" alt="Livebook" width="400"></h1>
<h1><img src="https://github.com/elixir-nx/livebook/raw/main/priv/static/images/logo-with-text.png" alt="Livebook" width="400"></h1>
Livebook is a web application for writing interactive and collaborative code notebooks. It features:

View file

@ -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

View file

@ -0,0 +1,3 @@
# Neural Networks with Axon
TODO: content 🐈

View file

@ -0,0 +1,3 @@
# Introduction to Elixir
TODO: content 🐈

View file

@ -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! 🚢

View file

@ -0,0 +1,3 @@
# Introduction to Nx
TODO: content 🐈

View file

@ -0,0 +1,3 @@
# Plotting with VegaLite and Kino
TODO: content 🐈

View file

@ -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

View file

@ -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"""
<div class="flex flex-grow h-full">
<%= live_component @socket, LivebookWeb.SidebarComponent,
id: :sidebar,
items: [
%{type: :logo},
%{type: :break},
%{type: :user, current_user: @current_user, path: Routes.explore_path(@socket, :user)}
] %>
<div class="flex-grow px-6 py-8 overflow-y-auto">
<div class="max-w-screen-md w-full mx-auto px-4 pb-8 space-y-8">
<div>
<div class="relative">
<%= 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 %>
<h1 class="text-3xl text-gray-800 font-semibold">
Explore
</h1>
</div>
<p class="mt-4 text-gray-700">
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!
</p>
</div>
<div class="p-8 bg-gray-900 rounded-2xl flex space-x-4 shadow-xl">
<div class="self-end max-w-sm">
<h3 class="text-xl text-gray-50 font-semibold">
<%= @lead_notebook_info.title %>
</h3>
<p class="mt-2 text-sm text-gray-300">
<%= @lead_notebook_info.description %>
</p>
<div class="mt-4">
<%= live_patch "Let's go", to: Routes.explore_path(@socket, :notebook, @lead_notebook_info.slug),
class: "button button-blue" %>
</div>
</div>
<div class="flex-grow hidden md:flex flex items-center justify-center">
<img src="<%= @lead_notebook_info.image_url %>" height="120" width="120" alt="livebook" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<%= for {info, idx} <- Enum.with_index(@notebook_infos) do %>
<%= live_component @socket, LivebookWeb.NotebookCardComponent,
id: "notebook-card-#{idx}",
notebook_info: info %>
<% end %>
</div>
</div>
</div>
</div>
<%= 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

View file

@ -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"""
<div class="flex flex-grow h-full">
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
<div class="flex-grow"></div>
<span class="tooltip right distant" aria-label="User profile">
<%= 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 %>
</span>
</div>
<%= live_component @socket, LivebookWeb.SidebarComponent,
id: :sidebar,
items: [
%{type: :break},
%{type: :user, current_user: @current_user, path: Routes.home_path(@socket, :user)}
] %>
<div class="flex-grow px-6 py-8 overflow-y-auto">
<div class="max-w-screen-lg w-full mx-auto p-4 pt-0 pb-8 flex flex-col items-center space-y-4">
<div class="w-full flex flex-col space-y-2 items-center sm:flex-row sm:space-y-0 sm:justify-between sm:pb-4 pb-8 border-b border-gray-200">
<div class="max-w-screen-lg w-full mx-auto px-4 pb-8 space-y-4">
<div class="flex flex-col space-y-2 items-center sm:flex-row sm:space-y-0 sm:justify-between sm:pb-4 pb-8 border-b border-gray-200">
<div class="text-2xl text-gray-800 font-semibold">
<img src="/logo-with-text.png" class="h-[50px]" alt="Livebook" />
<img src="/images/logo-with-text.png" class="h-[50px]" alt="Livebook" />
</div>
<div class="flex space-x-2 pt-2">
<span class="tooltip top" aria-label="Introduction">
<button class="button button-outlined-gray button-square-icon"
phx-click="open_welcome">
<%= remix_icon("compass-line") %>
</button>
</span>
<%= 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
</button>
</div>
</div>
<div class="w-full h-80">
<div class="h-80">
<%= live_component @socket, LivebookWeb.PathSelectComponent,
id: "path_select",
path: @path,
@ -92,10 +86,29 @@ defmodule LivebookWeb.HomeLive do
</div>
<% end %>
</div>
<div class="w-full py-12">
<h3 class="text-xl font-semibold text-gray-800 mb-5">
<div class="py-12">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-800">
Explore
</h2>
<%= live_redirect to: Routes.explore_path(@socket, :page),
class: "flex items-center text-blue-600" do %>
<span class="font-semibold">See all</span>
<%= remix_icon("arrow-right-line", class: "align-middle ml-1") %>
<% end %>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%= for {info, idx} <- Enum.with_index(@notebook_infos) do %>
<%= live_component @socket, LivebookWeb.NotebookCardComponent,
id: "notebook-card-#{idx}",
notebook_info: info %>
<% end %>
</div>
</div>
<div class="py-12">
<h2 class="mb-4 text-xl font-semibold text-gray-800">
Running sessions
</h3>
</h2>
<%= if @session_summaries == [] do %>
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
@ -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)

View file

@ -0,0 +1,22 @@
defmodule LivebookWeb.NotebookCardComponent do
use LivebookWeb, :live_component
@impl true
def render(assigns) do
~L"""
<div class="flex flex-col">
<div class="flex items-center justify-center p-6 border-2 border-gray-100 rounded-t-2xl h-[150px]">
<img src="<%= @notebook_info.image_url %>" class="max-h-full max-w-[75%]" />
</div>
<div class="px-6 py-4 bg-gray-100 rounded-b-2xl flex-grow">
<%= live_redirect @notebook_info.title,
to: Routes.explore_path(@socket, :notebook, @notebook_info.slug),
class: "text-gray-800 font-semibold cursor-pointer" %>
<p class="mt-2 text-sm text-gray-600">
<%= @notebook_info.description %>
</p>
</div>
</div>
"""
end
end

View file

@ -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

View file

@ -71,40 +71,41 @@ defmodule LivebookWeb.SessionLive do
id="session"
data-element="session"
phx-hook="Session">
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
<%= live_patch to: Routes.home_path(@socket, :page) do %>
<img src="/logo.png" height="40" width="40" alt="livebook" />
<% end %>
<span class="tooltip right distant" aria-label="Sections (ss)">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="sections-list-toggle">
<%= remix_icon("booklet-fill") %>
</button>
</span>
<span class="tooltip right distant" aria-label="Connected users (su)">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="clients-list-toggle">
<%= remix_icon("group-fill") %>
</button>
</span>
<span class="tooltip right distant" aria-label="Runtime settings (sr)">
<%= 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 %>
</span>
<div class="flex-grow"></div>
<span class="tooltip right distant" aria-label="Keyboard shortcuts (?)">
<%= 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 %>
</span>
<span class="tooltip right distant" aria-label="User profile">
<%= 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 %>
</span>
</div>
<%= 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)}
] %>
<div class="flex flex-col h-full w-full max-w-xs absolute z-30 top-0 left-[64px] shadow-xl md:static md:shadow-none overflow-y-auto bg-gray-50 border-r border-gray-100 px-6 py-10"
data-element="side-panel">
<div data-element="sections-list">

View file

@ -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"""
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
<%= for item <- @items do %>
<%= render_item(@socket, item) %>
<% end %>
</div>
"""
end
defp render_item(socket, %{type: :logo} = item) do
assigns = %{item: item}
~L"""
<%= live_patch to: Routes.home_path(socket, :page) do %>
<img src="/images/logo.png" height="40" width="40" alt="livebook" />
<% end %>
"""
end
defp render_item(_socket, %{type: :button} = item) do
assigns = %{item: item}
~L"""
<span class="tooltip right distant" aria-label="<%= item.label %>">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center"
data-element="<%= item.data_element %>">
<%= remix_icon(item.icon) %>
</button>
</span>
"""
end
defp render_item(_socket, %{type: :link} = item) do
assigns = %{item: item}
~L"""
<span class="tooltip right distant" aria-label="<%= item.label %>">
<%= 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 %>
</span>
"""
end
defp render_item(_socket, %{type: :break}) do
assigns = %{}
~L"""
<div class="flex-grow"></div>
"""
end
defp render_item(_socket, %{type: :user} = item) do
assigns = %{item: item}
~L"""
<span class="tooltip right distant" aria-label="User profile">
<%= 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 %>
</span>
"""
end
end

View file

@ -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

View file

@ -1,7 +1,7 @@
<div class="h-screen flex items-center justify-center bg-gray-900">
<div class="flex flex-col space-y-4 items-center">
<a href="/">
<img src="/logo.png" height="128" width="128" alt="livebook" />
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
</a>
<div class="text-2xl text-gray-50">
Authentication required

View file

@ -11,7 +11,7 @@
<div class="h-screen flex items-center justify-center bg-gray-900">
<div class="flex flex-col space-y-4 items-center">
<a href="/">
<img src="/logo.png" height="128" width="128" alt="livebook" />
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
</a>
<div class="text-2xl text-gray-50">
Authentication required

View file

@ -11,7 +11,7 @@
<div class="h-screen flex items-center justify-center bg-gray-900">
<div class="flex flex-col space-y-4 items-center">
<a href="/">
<img src="/logo.png" height="128" width="128" alt="livebook" />
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
</a>
<div class="text-2xl text-gray-50">
No Numbats here!

View file

@ -11,7 +11,7 @@
<div class="h-screen flex items-center justify-center bg-gray-900">
<div class="flex flex-col space-y-4 items-center">
<a href="/">
<img src="/logo.png" height="128" width="128" alt="livebook" />
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
</a>
<div class="text-2xl text-gray-50">
Something went wrong.

BIN
priv/static/images/axon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
priv/static/images/nx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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/"