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
|
|
@ -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:
|
Livebook is a web application for writing interactive and collaborative code notebooks. It features:
|
||||||
|
|
||||||
|
|
|
||||||
114
lib/livebook/notebook/explore.ex
Normal 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
|
||||||
3
lib/livebook/notebook/explore/intro_to_axon.livemd
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Neural Networks with Axon
|
||||||
|
|
||||||
|
TODO: content 🐈
|
||||||
3
lib/livebook/notebook/explore/intro_to_elixir.livemd
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Introduction to Elixir
|
||||||
|
|
||||||
|
TODO: content 🐈
|
||||||
213
lib/livebook/notebook/explore/intro_to_livebook.livemd
Normal 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! 🚢
|
||||||
3
lib/livebook/notebook/explore/intro_to_nx.livemd
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Introduction to Nx
|
||||||
|
|
||||||
|
TODO: content 🐈
|
||||||
3
lib/livebook/notebook/explore/intro_to_vega_lite.livemd
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Plotting with VegaLite and Kino
|
||||||
|
|
||||||
|
TODO: content 🐈
|
||||||
|
|
@ -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
|
|
||||||
108
lib/livebook_web/live/explore_live.ex
Normal 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
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule LivebookWeb.HomeLive do
|
||||||
use LivebookWeb, :live_view
|
use LivebookWeb, :live_view
|
||||||
|
|
||||||
import LivebookWeb.UserHelpers
|
import LivebookWeb.UserHelpers
|
||||||
|
import LivebookWeb.SessionHelpers
|
||||||
|
|
||||||
alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook}
|
alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook}
|
||||||
|
|
||||||
|
|
@ -14,12 +15,14 @@ defmodule LivebookWeb.HomeLive do
|
||||||
|
|
||||||
current_user = build_current_user(current_user_id, socket)
|
current_user = build_current_user(current_user_id, socket)
|
||||||
session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries())
|
session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries())
|
||||||
|
notebook_infos = Notebook.Explore.notebook_infos() |> Enum.take(3)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
current_user: current_user,
|
current_user: current_user,
|
||||||
path: default_path(),
|
path: default_path(),
|
||||||
session_summaries: session_summaries
|
session_summaries: session_summaries,
|
||||||
|
notebook_infos: notebook_infos
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -27,28 +30,19 @@ defmodule LivebookWeb.HomeLive do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~L"""
|
~L"""
|
||||||
<div class="flex flex-grow h-full">
|
<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">
|
<%= live_component @socket, LivebookWeb.SidebarComponent,
|
||||||
<div class="flex-grow"></div>
|
id: :sidebar,
|
||||||
<span class="tooltip right distant" aria-label="User profile">
|
items: [
|
||||||
<%= live_patch to: Routes.home_path(@socket, :user),
|
%{type: :break},
|
||||||
class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %>
|
%{type: :user, current_user: @current_user, path: Routes.home_path(@socket, :user)}
|
||||||
<%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %>
|
] %>
|
||||||
<% end %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-6 py-8 overflow-y-auto">
|
<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="max-w-screen-lg w-full mx-auto px-4 pb-8 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="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">
|
<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>
|
||||||
<div class="flex space-x-2 pt-2">
|
<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"),
|
<%= live_patch to: Routes.home_path(@socket, :import, "url"),
|
||||||
class: "button button-outlined-gray whitespace-nowrap" do %>
|
class: "button button-outlined-gray whitespace-nowrap" do %>
|
||||||
Import
|
Import
|
||||||
|
|
@ -59,7 +53,7 @@ defmodule LivebookWeb.HomeLive do
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-80">
|
<div class="h-80">
|
||||||
<%= live_component @socket, LivebookWeb.PathSelectComponent,
|
<%= live_component @socket, LivebookWeb.PathSelectComponent,
|
||||||
id: "path_select",
|
id: "path_select",
|
||||||
path: @path,
|
path: @path,
|
||||||
|
|
@ -92,10 +86,29 @@ defmodule LivebookWeb.HomeLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full py-12">
|
<div class="py-12">
|
||||||
<h3 class="text-xl font-semibold text-gray-800 mb-5">
|
<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
|
Running sessions
|
||||||
</h3>
|
</h2>
|
||||||
<%= if @session_summaries == [] do %>
|
<%= if @session_summaries == [] do %>
|
||||||
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
|
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -160,12 +173,8 @@ defmodule LivebookWeb.HomeLive do
|
||||||
{:noreply, assign(socket, path: path)}
|
{:noreply, assign(socket, path: path)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("open_welcome", %{}, socket) do
|
|
||||||
create_session(socket, notebook: Livebook.Notebook.Welcome.new())
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("new", %{}, socket) do
|
def handle_event("new", %{}, socket) do
|
||||||
create_session(socket)
|
{:noreply, create_session(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("fork", %{}, socket) do
|
def handle_event("fork", %{}, socket) do
|
||||||
|
|
@ -173,20 +182,20 @@ defmodule LivebookWeb.HomeLive do
|
||||||
socket = put_import_flash_messages(socket, messages)
|
socket = put_import_flash_messages(socket, messages)
|
||||||
notebook = Notebook.forked(notebook)
|
notebook = Notebook.forked(notebook)
|
||||||
images_dir = Session.images_dir_for_notebook(socket.assigns.path)
|
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
|
end
|
||||||
|
|
||||||
def handle_event("open", %{}, socket) do
|
def handle_event("open", %{}, socket) do
|
||||||
{notebook, messages} = import_notebook(socket.assigns.path)
|
{notebook, messages} = import_notebook(socket.assigns.path)
|
||||||
socket = put_import_flash_messages(socket, messages)
|
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
|
end
|
||||||
|
|
||||||
def handle_event("fork_session", %{"id" => session_id}, socket) do
|
def handle_event("fork_session", %{"id" => session_id}, socket) do
|
||||||
data = Session.get_data(session_id)
|
data = Session.get_data(session_id)
|
||||||
notebook = Notebook.forked(data.notebook)
|
notebook = Notebook.forked(data.notebook)
|
||||||
%{images_dir: images_dir} = Session.get_summary(session_id)
|
%{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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -204,7 +213,7 @@ defmodule LivebookWeb.HomeLive do
|
||||||
def handle_info({:import_content, content}, socket) do
|
def handle_info({:import_content, content}, socket) do
|
||||||
{notebook, messages} = Livebook.LiveMarkdown.Import.notebook_from_markdown(content)
|
{notebook, messages} = Livebook.LiveMarkdown.Import.notebook_from_markdown(content)
|
||||||
socket = put_import_flash_messages(socket, messages)
|
socket = put_import_flash_messages(socket, messages)
|
||||||
create_session(socket, notebook: notebook)
|
{:noreply, create_session(socket, notebook: notebook)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(
|
def handle_info(
|
||||||
|
|
@ -246,16 +255,6 @@ defmodule LivebookWeb.HomeLive do
|
||||||
end
|
end
|
||||||
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
|
defp import_notebook(path) do
|
||||||
content = File.read!(path)
|
content = File.read!(path)
|
||||||
LiveMarkdown.Import.notebook_from_markdown(content)
|
LiveMarkdown.Import.notebook_from_markdown(content)
|
||||||
|
|
|
||||||
22
lib/livebook_web/live/notebook_card_component.ex
Normal 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
|
||||||
21
lib/livebook_web/live/session_helpers.ex
Normal 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
|
||||||
|
|
@ -71,40 +71,41 @@ defmodule LivebookWeb.SessionLive do
|
||||||
id="session"
|
id="session"
|
||||||
data-element="session"
|
data-element="session"
|
||||||
phx-hook="Session">
|
phx-hook="Session">
|
||||||
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
<%= live_component @socket, LivebookWeb.SidebarComponent,
|
||||||
<%= live_patch to: Routes.home_path(@socket, :page) do %>
|
id: :sidebar,
|
||||||
<img src="/logo.png" height="40" width="40" alt="livebook" />
|
items: [
|
||||||
<% end %>
|
%{type: :logo},
|
||||||
<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">
|
type: :button,
|
||||||
<%= remix_icon("booklet-fill") %>
|
data_element: "sections-list-toggle",
|
||||||
</button>
|
icon: "booklet-fill",
|
||||||
</span>
|
label: "Sections (ss)",
|
||||||
<span class="tooltip right distant" aria-label="Connected users (su)">
|
active: false
|
||||||
<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>
|
type: :button,
|
||||||
</span>
|
data_element: "clients-list-toggle",
|
||||||
<span class="tooltip right distant" aria-label="Runtime settings (sr)">
|
icon: "group-fill",
|
||||||
<%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id),
|
label: "Connected users (su)",
|
||||||
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 %>
|
active: false
|
||||||
<%= remix_icon("cpu-line", class: "text-2xl") %>
|
},
|
||||||
<% end %>
|
%{
|
||||||
</span>
|
type: :link,
|
||||||
<div class="flex-grow"></div>
|
icon: "cpu-line",
|
||||||
<span class="tooltip right distant" aria-label="Keyboard shortcuts (?)">
|
path: Routes.session_path(@socket, :runtime_settings, @session_id),
|
||||||
<%= live_patch to: Routes.session_path(@socket, :shortcuts, @session_id),
|
label: "Runtime settings (sr)",
|
||||||
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 %>
|
active: @live_action == :runtime_settings
|
||||||
<%= remix_icon("keyboard-box-fill", class: "text-2xl") %>
|
},
|
||||||
<% end %>
|
%{type: :break},
|
||||||
</span>
|
%{
|
||||||
<span class="tooltip right distant" aria-label="User profile">
|
type: :link,
|
||||||
<%= live_patch to: Routes.session_path(@socket, :user, @session_id),
|
icon: "keyboard-box-fill",
|
||||||
class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %>
|
path: Routes.session_path(@socket, :shortcuts, @session_id),
|
||||||
<%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %>
|
label: "Keyboard shortcuts (?)",
|
||||||
<% end %>
|
active: @live_action == :shortcuts
|
||||||
</span>
|
},
|
||||||
</div>
|
%{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"
|
<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">
|
data-element="side-panel">
|
||||||
<div data-element="sections-list">
|
<div data-element="sections-list">
|
||||||
|
|
|
||||||
77
lib/livebook_web/live/sidebar_component.ex
Normal 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
|
||||||
|
|
@ -23,6 +23,9 @@ defmodule LivebookWeb.Router do
|
||||||
live "/home/user-profile", HomeLive, :user
|
live "/home/user-profile", HomeLive, :user
|
||||||
live "/home/import/:tab", HomeLive, :import
|
live "/home/import/:tab", HomeLive, :import
|
||||||
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
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", SessionLive, :page
|
||||||
live "/sessions/:id/user-profile", SessionLive, :user
|
live "/sessions/:id/user-profile", SessionLive, :user
|
||||||
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
|
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
|
||||||
</a>
|
</a>
|
||||||
<div class="text-2xl text-gray-50">
|
<div class="text-2xl text-gray-50">
|
||||||
Authentication required
|
Authentication required
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
|
||||||
</a>
|
</a>
|
||||||
<div class="text-2xl text-gray-50">
|
<div class="text-2xl text-gray-50">
|
||||||
Authentication required
|
Authentication required
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
|
||||||
</a>
|
</a>
|
||||||
<div class="text-2xl text-gray-50">
|
<div class="text-2xl text-gray-50">
|
||||||
No Numbats here!
|
No Numbats here!
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
|
||||||
</a>
|
</a>
|
||||||
<div class="text-2xl text-gray-50">
|
<div class="text-2xl text-gray-50">
|
||||||
Something went wrong.
|
Something went wrong.
|
||||||
|
|
|
||||||
BIN
priv/static/images/axon.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
priv/static/images/elixir.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
BIN
priv/static/images/nx.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
priv/static/images/vega_lite.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
18
test/livebook/explore_test.exs
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
19
test/livebook_web/live/explore_live_test.exs
Normal 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
|
||||||
|
|
@ -163,8 +163,9 @@ defmodule LivebookWeb.HomeLiveTest do
|
||||||
|
|
||||||
assert {:error, {:live_redirect, %{to: to}}} =
|
assert {:error, {:live_redirect, %{to: to}}} =
|
||||||
view
|
view
|
||||||
|> element(~s{[aria-label="Introduction"] button})
|
|> element(~s{a}, "Welcome to Livebook")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|> follow_redirect(conn)
|
||||||
|
|
||||||
assert to =~ "/sessions/"
|
assert to =~ "/sessions/"
|
||||||
|
|
||||||
|
|
|
||||||