aboutsummaryrefslogtreecommitdiff
path: root/lib/nulla_web/components
diff options
context:
space:
mode:
Diffstat (limited to 'lib/nulla_web/components')
-rw-r--r--lib/nulla_web/components/core_components.ex676
-rw-r--r--lib/nulla_web/components/layouts.ex14
-rw-r--r--lib/nulla_web/components/layouts/app.html.heex32
-rw-r--r--lib/nulla_web/components/layouts/root.html.heex17
4 files changed, 739 insertions, 0 deletions
diff --git a/lib/nulla_web/components/core_components.ex b/lib/nulla_web/components/core_components.ex
new file mode 100644
index 0000000..3687e1e
--- /dev/null
+++ b/lib/nulla_web/components/core_components.ex
@@ -0,0 +1,676 @@
+defmodule NullaWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components.
+
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as modals, tables, and
+ forms. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+
+ The default components use Tailwind CSS, a utility-first CSS framework.
+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+ how to customize them or feel free to swap in another framework altogether.
+
+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+ """
+ use Phoenix.Component
+ use Gettext, backend: NullaWeb.Gettext
+
+ alias Phoenix.LiveView.JS
+
+ @doc """
+ Renders a modal.
+
+ ## Examples
+
+ <.modal id="confirm-modal">
+ This is a modal.
+ </.modal>
+
+ JS commands may be passed to the `:on_cancel` to configure
+ the closing/cancel event, for example:
+
+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+ This is another modal.
+ </.modal>
+
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :on_cancel, JS, default: %JS{}
+ slot :inner_block, required: true
+
+ def modal(assigns) do
+ ~H"""
+ <div
+ id={@id}
+ phx-mounted={@show && show_modal(@id)}
+ phx-remove={hide_modal(@id)}
+ data-cancel={JS.exec(@on_cancel, "phx-remove")}
+ class="relative z-50 hidden"
+ >
+ <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
+ <div
+ class="fixed inset-0 overflow-y-auto"
+ aria-labelledby={"#{@id}-title"}
+ aria-describedby={"#{@id}-description"}
+ role="dialog"
+ aria-modal="true"
+ tabindex="0"
+ >
+ <div class="flex min-h-full items-center justify-center">
+ <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
+ <.focus_wrap
+ id={"#{@id}-container"}
+ phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
+ phx-key="escape"
+ phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
+ class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
+ >
+ <div class="absolute top-6 right-5">
+ <button
+ phx-click={JS.exec("data-cancel", to: "##{@id}")}
+ type="button"
+ class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
+ aria-label={gettext("close")}
+ >
+ <.icon name="hero-x-mark-solid" class="h-5 w-5" />
+ </button>
+ </div>
+ <div id={"#{@id}-content"}>
+ {render_slot(@inner_block)}
+ </div>
+ </.focus_wrap>
+ </div>
+ </div>
+ </div>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders flash notices.
+
+ ## Examples
+
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
+ """
+ attr :id, :string, doc: "the optional id of flash container"
+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+ attr :title, :string, default: nil
+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+ slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+ def flash(assigns) do
+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+ ~H"""
+ <div
+ :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
+ id={@id}
+ phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
+ role="alert"
+ class={[
+ "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
+ @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
+ @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+ ]}
+ {@rest}
+ >
+ <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
+ <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
+ <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
+ {@title}
+ </p>
+ <p class="mt-2 text-sm leading-5">{msg}</p>
+ <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
+ <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
+ </button>
+ </div>
+ """
+ end
+
+ @doc """
+ Shows the flash group with standard titles and content.
+
+ ## Examples
+
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+ def flash_group(assigns) do
+ ~H"""
+ <div id={@id}>
+ <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
+ <.flash kind={:error} title={gettext("Error!")} flash={@flash} />
+ <.flash
+ id="client-error"
+ kind={:error}
+ title={gettext("We can't find the internet")}
+ phx-disconnected={show(".phx-client-error #client-error")}
+ phx-connected={hide("#client-error")}
+ hidden
+ >
+ {gettext("Attempting to reconnect")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+ </.flash>
+
+ <.flash
+ id="server-error"
+ kind={:error}
+ title={gettext("Something went wrong!")}
+ phx-disconnected={show(".phx-server-error #server-error")}
+ phx-connected={hide("#server-error")}
+ hidden
+ >
+ {gettext("Hang in there while we get back on track")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+ </.flash>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a simple form.
+
+ ## Examples
+
+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
+ <.input field={@form[:email]} label="Email"/>
+ <.input field={@form[:username]} label="Username" />
+ <:actions>
+ <.button>Save</.button>
+ </:actions>
+ </.simple_form>
+ """
+ attr :for, :any, required: true, doc: "the data structure for the form"
+ attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
+
+ attr :rest, :global,
+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
+ doc: "the arbitrary HTML attributes to apply to the form tag"
+
+ slot :inner_block, required: true
+ slot :actions, doc: "the slot for form actions, such as a submit button"
+
+ def simple_form(assigns) do
+ ~H"""
+ <.form :let={f} for={@for} as={@as} {@rest}>
+ <div class="mt-10 space-y-8 bg-white">
+ {render_slot(@inner_block, f)}
+ <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
+ {render_slot(action, f)}
+ </div>
+ </div>
+ </.form>
+ """
+ end
+
+ @doc """
+ Renders a button.
+
+ ## Examples
+
+ <.button>Send!</.button>
+ <.button phx-click="go" class="ml-2">Send!</.button>
+ """
+ attr :type, :string, default: nil
+ attr :class, :string, default: nil
+ attr :rest, :global, include: ~w(disabled form name value)
+
+ slot :inner_block, required: true
+
+ def button(assigns) do
+ ~H"""
+ <button
+ type={@type}
+ class={[
+ "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
+ "text-sm font-semibold leading-6 text-white active:text-white/80",
+ @class
+ ]}
+ {@rest}
+ >
+ {render_slot(@inner_block)}
+ </button>
+ """
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `<select>` tag
+
+ * `type="checkbox"` is used exclusively to render boolean values
+
+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
+
+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
+ for more information. Unsupported types, such as hidden and radio,
+ are best written directly in your templates.
+
+ ## Examples
+
+ <.input field={@form[:email]} type="email" />
+ <.input name="my-input" errors={["oh no!"]} />
+ """
+ attr :id, :any, default: nil
+ attr :name, :any
+ attr :label, :string, default: nil
+ attr :value, :any
+
+ attr :type, :string,
+ default: "text",
+ values: ~w(checkbox color date datetime-local email file month number password
+ range search select tel text textarea time url week)
+
+ attr :field, Phoenix.HTML.FormField,
+ doc: "a form field struct retrieved from the form, for example: @form[:email]"
+
+ attr :errors, :list, default: []
+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
+
+ attr :rest, :global,
+ include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
+ multiple pattern placeholder readonly required rows size step)
+
+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
+
+ assigns
+ |> assign(field: nil, id: assigns.id || field.id)
+ |> assign(:errors, Enum.map(errors, &translate_error(&1)))
+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
+ |> assign_new(:value, fn -> field.value end)
+ |> input()
+ end
+
+ def input(%{type: "checkbox"} = assigns) do
+ assigns =
+ assign_new(assigns, :checked, fn ->
+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+ end)
+
+ ~H"""
+ <div>
+ <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
+ <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
+ <input
+ type="checkbox"
+ id={@id}
+ name={@name}
+ value="true"
+ checked={@checked}
+ class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
+ {@rest}
+ />
+ {@label}
+ </label>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </div>
+ """
+ end
+
+ def input(%{type: "select"} = assigns) do
+ ~H"""
+ <div>
+ <.label for={@id}>{@label}</.label>
+ <select
+ id={@id}
+ name={@name}
+ class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
+ multiple={@multiple}
+ {@rest}
+ >
+ <option :if={@prompt} value="">{@prompt}</option>
+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
+ </select>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </div>
+ """
+ end
+
+ def input(%{type: "textarea"} = assigns) do
+ ~H"""
+ <div>
+ <.label for={@id}>{@label}</.label>
+ <textarea
+ id={@id}
+ name={@name}
+ class={[
+ "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
+ @errors == [] && "border-zinc-300 focus:border-zinc-400",
+ @errors != [] && "border-rose-400 focus:border-rose-400"
+ ]}
+ {@rest}
+ >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </div>
+ """
+ end
+
+ # All other inputs text, datetime-local, url, password, etc. are handled here...
+ def input(assigns) do
+ ~H"""
+ <div>
+ <.label for={@id}>{@label}</.label>
+ <input
+ type={@type}
+ name={@name}
+ id={@id}
+ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
+ class={[
+ "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+ @errors == [] && "border-zinc-300 focus:border-zinc-400",
+ @errors != [] && "border-rose-400 focus:border-rose-400"
+ ]}
+ {@rest}
+ />
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a label.
+ """
+ attr :for, :string, default: nil
+ slot :inner_block, required: true
+
+ def label(assigns) do
+ ~H"""
+ <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
+ {render_slot(@inner_block)}
+ </label>
+ """
+ end
+
+ @doc """
+ Generates a generic error message.
+ """
+ slot :inner_block, required: true
+
+ def error(assigns) do
+ ~H"""
+ <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
+ {render_slot(@inner_block)}
+ </p>
+ """
+ end
+
+ @doc """
+ Renders a header with title.
+ """
+ attr :class, :string, default: nil
+
+ slot :inner_block, required: true
+ slot :subtitle
+ slot :actions
+
+ def header(assigns) do
+ ~H"""
+ <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
+ <div>
+ <h1 class="text-lg font-semibold leading-8 text-zinc-800">
+ {render_slot(@inner_block)}
+ </h1>
+ <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
+ {render_slot(@subtitle)}
+ </p>
+ </div>
+ <div class="flex-none">{render_slot(@actions)}</div>
+ </header>
+ """
+ end
+
+ @doc ~S"""
+ Renders a table with generic styling.
+
+ ## Examples
+
+ <.table id="users" rows={@users}>
+ <:col :let={user} label="id">{user.id}</:col>
+ <:col :let={user} label="username">{user.username}</:col>
+ </.table>
+ """
+ attr :id, :string, required: true
+ attr :rows, :list, required: true
+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
+
+ attr :row_item, :any,
+ default: &Function.identity/1,
+ doc: "the function for mapping each row before calling the :col and :action slots"
+
+ slot :col, required: true do
+ attr :label, :string
+ end
+
+ slot :action, doc: "the slot for showing user actions in the last table column"
+
+ def table(assigns) do
+ assigns =
+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
+ end
+
+ ~H"""
+ <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
+ <table class="w-[40rem] mt-11 sm:w-full">
+ <thead class="text-sm text-left leading-6 text-zinc-500">
+ <tr>
+ <th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
+ <th :if={@action != []} class="relative p-0 pb-4">
+ <span class="sr-only">{gettext("Actions")}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody
+ id={@id}
+ phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
+ class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
+ >
+ <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
+ <td
+ :for={{col, i} <- Enum.with_index(@col)}
+ phx-click={@row_click && @row_click.(row)}
+ class={["relative p-0", @row_click && "hover:cursor-pointer"]}
+ >
+ <div class="block py-4 pr-6">
+ <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
+ <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
+ {render_slot(col, @row_item.(row))}
+ </span>
+ </div>
+ </td>
+ <td :if={@action != []} class="relative w-14 p-0">
+ <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
+ <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
+ <span
+ :for={action <- @action}
+ class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
+ >
+ {render_slot(action, @row_item.(row))}
+ </span>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a data list.
+
+ ## Examples
+
+ <.list>
+ <:item title="Title">{@post.title}</:item>
+ <:item title="Views">{@post.views}</:item>
+ </.list>
+ """
+ slot :item, required: true do
+ attr :title, :string, required: true
+ end
+
+ def list(assigns) do
+ ~H"""
+ <div class="mt-14">
+ <dl class="-my-4 divide-y divide-zinc-100">
+ <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
+ <dt class="w-1/4 flex-none text-zinc-500">{item.title}</dt>
+ <dd class="text-zinc-700">{render_slot(item)}</dd>
+ </div>
+ </dl>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a back navigation link.
+
+ ## Examples
+
+ <.back navigate={~p"/posts"}>Back to posts</.back>
+ """
+ attr :navigate, :any, required: true
+ slot :inner_block, required: true
+
+ def back(assigns) do
+ ~H"""
+ <div class="mt-16">
+ <.link
+ navigate={@navigate}
+ class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
+ >
+ <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
+ {render_slot(@inner_block)}
+ </.link>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a [Heroicon](https://heroicons.com).
+
+ Heroicons come in three styles – outline, solid, and mini.
+ By default, the outline style is used, but solid and mini may
+ be applied by using the `-solid` and `-mini` suffix.
+
+ You can customize the size and colors of the icons by setting
+ width, height, and background color classes.
+
+ Icons are extracted from the `deps/heroicons` directory and bundled within
+ your compiled app.css by the plugin in your `assets/tailwind.config.js`.
+
+ ## Examples
+
+ <.icon name="hero-x-mark-solid" />
+ <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
+ """
+ attr :name, :string, required: true
+ attr :class, :string, default: nil
+
+ def icon(%{name: "hero-" <> _} = assigns) do
+ ~H"""
+ <span class={[@name, @class]} />
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ time: 300,
+ transition:
+ {"transition-all transform ease-out duration-300",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+ "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition:
+ {"transition-all transform ease-in duration-200",
+ "opacity-100 translate-y-0 sm:scale-100",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ def show_modal(js \\ %JS{}, id) when is_binary(id) do
+ js
+ |> JS.show(to: "##{id}")
+ |> JS.show(
+ to: "##{id}-bg",
+ time: 300,
+ transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
+ )
+ |> show("##{id}-container")
+ |> JS.add_class("overflow-hidden", to: "body")
+ |> JS.focus_first(to: "##{id}-content")
+ end
+
+ def hide_modal(js \\ %JS{}, id) do
+ js
+ |> JS.hide(
+ to: "##{id}-bg",
+ transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
+ )
+ |> hide("##{id}-container")
+ |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
+ |> JS.remove_class("overflow-hidden", to: "body")
+ |> JS.pop_focus()
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # When using gettext, we typically pass the strings we want
+ # to translate as a static argument:
+ #
+ # # Translate the number of files with plural rules
+ # dngettext("errors", "1 file", "%{count} files", count)
+ #
+ # However the error messages in our forms and APIs are generated
+ # dynamically, so we need to translate them by calling Gettext
+ # with our gettext backend as first argument. Translations are
+ # available in the errors.po file (as we use the "errors" domain).
+ if count = opts[:count] do
+ Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts)
+ end
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+end
diff --git a/lib/nulla_web/components/layouts.ex b/lib/nulla_web/components/layouts.ex
new file mode 100644
index 0000000..0e335fa
--- /dev/null
+++ b/lib/nulla_web/components/layouts.ex
@@ -0,0 +1,14 @@
+defmodule NullaWeb.Layouts do
+ @moduledoc """
+ This module holds different layouts used by your application.
+
+ See the `layouts` directory for all templates available.
+ The "root" layout is a skeleton rendered as part of the
+ application router. The "app" layout is set as the default
+ layout on both `use NullaWeb, :controller` and
+ `use NullaWeb, :live_view`.
+ """
+ use NullaWeb, :html
+
+ embed_templates "layouts/*"
+end
diff --git a/lib/nulla_web/components/layouts/app.html.heex b/lib/nulla_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..3b3b607
--- /dev/null
+++ b/lib/nulla_web/components/layouts/app.html.heex
@@ -0,0 +1,32 @@
+<header class="px-4 sm:px-6 lg:px-8">
+ <div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
+ <div class="flex items-center gap-4">
+ <a href="/">
+ <img src={~p"/images/logo.svg"} width="36" />
+ </a>
+ <p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
+ v{Application.spec(:phoenix, :vsn)}
+ </p>
+ </div>
+ <div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
+ <a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
+ @elixirphoenix
+ </a>
+ <a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
+ GitHub
+ </a>
+ <a
+ href="https://hexdocs.pm/phoenix/overview.html"
+ class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
+ >
+ Get Started <span aria-hidden="true">&rarr;</span>
+ </a>
+ </div>
+ </div>
+</header>
+<main class="px-4 py-20 sm:px-6 lg:px-8">
+ <div class="mx-auto max-w-2xl">
+ <.flash_group flash={@flash} />
+ {@inner_content}
+ </div>
+</main>
diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..ea6a36d
--- /dev/null
+++ b/lib/nulla_web/components/layouts/root.html.heex
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en" class="[scrollbar-gutter:stable]">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="csrf-token" content={get_csrf_token()} />
+ <.live_title default="Nulla" suffix=" · Phoenix Framework">
+ {assigns[:page_title]}
+ </.live_title>
+ <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
+ <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
+ </script>
+ </head>
+ <body class="bg-white">
+ {@inner_content}
+ </body>
+</html>