- Replace gray->cool-steel, blue/indigo->cerulean, red->sweet-salmon, yellow/amber->wheat - Dark sidebar: sidebar/footer use cool-steel-800 bg, text lightened for contrast - ~11,600 color replacements across ~99 files - Update tests for new color class names
990 lines
36 KiB
Elixir
990 lines
36 KiB
Elixir
defmodule ToweropsWeb.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 tables, forms, and
|
||
inputs. 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 foundation for styling is Tailwind CSS, a utility-first CSS framework.
|
||
Here are useful references:
|
||
|
||
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||
spacing.
|
||
|
||
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||
|
||
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||
the component system used by Phoenix. Some components, such as `<.link>`
|
||
and `<.form>`, are defined there.
|
||
|
||
"""
|
||
use Phoenix.Component
|
||
use Gettext, backend: ToweropsWeb.Gettext
|
||
|
||
alias Phoenix.HTML.Form
|
||
alias Phoenix.HTML.FormField
|
||
alias Phoenix.LiveView.JS
|
||
|
||
@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}")}
|
||
phx-hook="AutoDismissFlash"
|
||
data-dismiss-after="30000"
|
||
role="alert"
|
||
aria-live="assertive"
|
||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
|
||
{@rest}
|
||
>
|
||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||
<div class="pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white opacity-100 shadow-lg outline-1 outline-black/5 transition duration-300 ease-out sm:translate-x-0 dark:bg-cool-steel-800 dark:-outline-offset-1 dark:outline-white/10 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0">
|
||
<div class="p-4">
|
||
<div class="flex items-start">
|
||
<div class="shrink-0">
|
||
<.icon
|
||
:if={@kind == :info}
|
||
name="hero-check-circle"
|
||
class="size-6 text-green-400"
|
||
/>
|
||
<.icon
|
||
:if={@kind == :error}
|
||
name="hero-x-circle"
|
||
class="size-6 text-sweet-salmon-400"
|
||
/>
|
||
</div>
|
||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||
<p :if={@title} class="text-sm font-medium text-cool-steel-900 dark:text-white">
|
||
{@title}
|
||
</p>
|
||
<p class={[
|
||
"text-sm text-cool-steel-500 dark:text-cool-steel-400",
|
||
@title && "mt-1"
|
||
]}>
|
||
{render_flash_message(msg)}
|
||
</p>
|
||
</div>
|
||
<div class="ml-4 flex shrink-0">
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded-md text-cool-steel-400 hover:text-cool-steel-500 focus:outline-2 focus:outline-offset-2 focus:outline-cerulean-600 dark:hover:text-white dark:focus:outline-cerulean-500"
|
||
aria-label={gettext("close")}
|
||
>
|
||
<span class="sr-only">Close</span>
|
||
<.icon name="hero-x-mark" class="size-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
# Renders flash message content, supporting both plain strings and maps with links
|
||
# Map format: %{text: "Message", link_text: "Link", link_url: "/path"}
|
||
defp render_flash_message(%{text: text, link_text: link_text, link_url: link_url}) do
|
||
assigns = %{text: text, link_text: link_text, link_url: link_url}
|
||
|
||
~H"""
|
||
{@text}
|
||
<.link
|
||
navigate={@link_url}
|
||
class="font-medium text-cerulean-600 hover:text-cerulean-500 dark:text-cerulean-400 dark:hover:text-cerulean-300"
|
||
>
|
||
{@link_text}
|
||
</.link>
|
||
"""
|
||
end
|
||
|
||
defp render_flash_message(msg), do: msg
|
||
|
||
@doc """
|
||
Renders a button with navigation support.
|
||
|
||
## Examples
|
||
|
||
<.button>Send!</.button>
|
||
<.button phx-click="go" variant="primary">Send!</.button>
|
||
<.button navigate={~p"/"}>Home</.button>
|
||
"""
|
||
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||
attr :class, :any
|
||
attr :variant, :string, values: ~w(primary secondary danger ghost)
|
||
attr :size, :string, default: "md", values: ~w(sm md lg)
|
||
attr :loading, :boolean, default: false
|
||
slot :inner_block, required: true
|
||
|
||
def button(%{rest: rest} = assigns) do
|
||
variants = %{
|
||
"primary" =>
|
||
"bg-cerulean-600 text-white hover:bg-cerulean-500 active:bg-cerulean-700 focus:ring-cerulean-500 shadow-md hover:shadow-lg dark:bg-cerulean-500 dark:hover:bg-cerulean-400 dark:active:bg-cerulean-600",
|
||
"secondary" =>
|
||
"bg-white text-cool-steel-700 ring-1 ring-inset ring-cool-steel-300 hover:bg-cool-steel-50 focus:ring-cerulean-500 dark:bg-cool-steel-800 dark:text-cool-steel-200 dark:ring-cool-steel-600 dark:hover:bg-cool-steel-700",
|
||
"danger" =>
|
||
"bg-sweet-salmon-600 text-white hover:bg-sweet-salmon-500 active:bg-sweet-salmon-700 focus:ring-sweet-salmon-500 dark:bg-sweet-salmon-500 dark:hover:bg-sweet-salmon-400",
|
||
"ghost" =>
|
||
"bg-transparent text-cool-steel-700 hover:bg-cool-steel-100 focus:ring-cerulean-500 shadow-none dark:text-cool-steel-300 dark:hover:bg-cool-steel-800",
|
||
nil =>
|
||
"bg-white text-cool-steel-900 ring-1 ring-inset ring-cool-steel-300 hover:bg-cool-steel-50 focus:ring-cerulean-500 dark:bg-cool-steel-800 dark:text-white dark:ring-cool-steel-700 dark:hover:bg-cool-steel-700"
|
||
}
|
||
|
||
sizes = %{
|
||
"sm" => "px-2.5 py-1.5 text-xs",
|
||
"md" => "px-3.5 py-2.5 text-sm",
|
||
"lg" => "px-5 py-3 text-base"
|
||
}
|
||
|
||
assigns =
|
||
assign_new(assigns, :class, fn ->
|
||
[
|
||
"inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-cool-steel-900",
|
||
Map.fetch!(sizes, assigns.size),
|
||
Map.fetch!(variants, assigns[:variant])
|
||
]
|
||
end)
|
||
|
||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||
~H"""
|
||
<.link class={@class} {@rest}>
|
||
<svg
|
||
:if={@loading}
|
||
class="animate-spin -ml-1 size-4"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||
</circle>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
>
|
||
</path>
|
||
</svg>
|
||
{render_slot(@inner_block)}
|
||
</.link>
|
||
"""
|
||
else
|
||
~H"""
|
||
<button class={@class} disabled={@loading || @rest[:disabled]} {@rest}>
|
||
<svg
|
||
:if={@loading}
|
||
class="animate-spin -ml-1 size-4"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||
</circle>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
>
|
||
</path>
|
||
</svg>
|
||
{render_slot(@inner_block)}
|
||
</button>
|
||
"""
|
||
end
|
||
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 radio, are best
|
||
written directly in your templates.
|
||
|
||
## Examples
|
||
|
||
```heex
|
||
<.input field={@form[:email]} type="email" />
|
||
<.input name="my-input" errors={["oh no!"]} />
|
||
```
|
||
|
||
## Select type
|
||
|
||
When using `type="select"`, you must pass the `options` and optionally
|
||
a `value` to mark which option should be preselected.
|
||
|
||
```heex
|
||
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
|
||
```
|
||
|
||
For more information on what kind of data can be passed to `options` see
|
||
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
|
||
"""
|
||
attr :id, :any, default: nil
|
||
attr :name, :any
|
||
attr :label, :string, default: nil
|
||
attr :value, :any, default: nil
|
||
|
||
attr :type, :string,
|
||
default: "text",
|
||
values: ~w(checkbox color date datetime-local email file month number password
|
||
search select tel text textarea time url week hidden)
|
||
|
||
attr :field, 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 :class, :any, default: nil, doc: "the input class to use over defaults"
|
||
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
|
||
|
||
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: %FormField{} = field} = assigns) do
|
||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||
|
||
value = if Map.has_key?(assigns.__given__, :value), do: assigns.value, else: field.value
|
||
|
||
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(:value, value)
|
||
|> input()
|
||
end
|
||
|
||
def input(%{type: "hidden"} = assigns) do
|
||
~H"""
|
||
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
|
||
"""
|
||
end
|
||
|
||
def input(%{type: "checkbox"} = assigns) do
|
||
assigns =
|
||
assigns
|
||
|> assign_new(:checked, fn ->
|
||
Form.normalize_value("checkbox", assigns[:value])
|
||
end)
|
||
|> assign_new(:error_id, fn -> assigns.id && "#{assigns.id}-error" end)
|
||
|
||
~H"""
|
||
<div class="mb-4">
|
||
<label class="flex items-center gap-3">
|
||
<input
|
||
type="hidden"
|
||
name={@name}
|
||
value="false"
|
||
disabled={@rest[:disabled]}
|
||
form={@rest[:form]}
|
||
/>
|
||
<input
|
||
type="checkbox"
|
||
id={@id}
|
||
name={@name}
|
||
value="true"
|
||
checked={@checked}
|
||
aria-describedby={@errors != [] && @error_id}
|
||
aria-invalid={to_string(@errors != [])}
|
||
class={
|
||
@class ||
|
||
"h-4 w-4 rounded border-cool-steel-300 text-cerulean-600 focus:ring-cerulean-500 dark:border-cool-steel-600 dark:bg-cool-steel-800"
|
||
}
|
||
{@rest}
|
||
/>
|
||
<span class="text-sm font-medium text-cool-steel-900 dark:text-white">{@label}</span>
|
||
</label>
|
||
<.error :if={@errors != []} id={@error_id}>
|
||
<span :for={msg <- @errors}>{msg}</span>
|
||
</.error>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
def input(%{type: "select"} = assigns) do
|
||
assigns = assign_new(assigns, :error_id, fn -> assigns.id && "#{assigns.id}-error" end)
|
||
|
||
~H"""
|
||
<div class="mb-4">
|
||
<label
|
||
:if={@label}
|
||
for={@id}
|
||
class="block text-sm/6 font-medium text-cool-steel-900 dark:text-white"
|
||
>
|
||
{@label}
|
||
</label>
|
||
<div class={["mt-2 grid grid-cols-1", @label && ""]}>
|
||
<select
|
||
id={@id}
|
||
name={@name}
|
||
aria-describedby={@errors != [] && @error_id}
|
||
aria-invalid={to_string(@errors != [])}
|
||
class={[
|
||
@class ||
|
||
"col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-cool-steel-900 outline-1 -outline-offset-1 placeholder:text-cool-steel-400 focus:outline-2 focus:-outline-offset-2 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-cool-steel-800",
|
||
@errors == [] &&
|
||
"outline-cool-steel-300 focus:outline-cerulean-600 dark:focus:outline-cerulean-500",
|
||
@errors != [] &&
|
||
(@error_class ||
|
||
"outline-sweet-salmon-300 focus:outline-sweet-salmon-500 dark:outline-sweet-salmon-700 dark:focus:outline-sweet-salmon-500")
|
||
]}
|
||
multiple={@multiple}
|
||
{@rest}
|
||
>
|
||
<option :if={@prompt} value="">{@prompt}</option>
|
||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||
</select>
|
||
<svg
|
||
viewBox="0 0 16 16"
|
||
fill="currentColor"
|
||
data-slot="icon"
|
||
aria-hidden="true"
|
||
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cool-steel-400 sm:size-4"
|
||
>
|
||
<path
|
||
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
|
||
clip-rule="evenodd"
|
||
fill-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<.error :if={@errors != []} id={@error_id}>
|
||
<span :for={msg <- @errors}>{msg}</span>
|
||
</.error>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
def input(%{type: "textarea"} = assigns) do
|
||
assigns = assign_new(assigns, :error_id, fn -> assigns.id && "#{assigns.id}-error" end)
|
||
|
||
~H"""
|
||
<div class="mb-4">
|
||
<label>
|
||
<span :if={@label} class="block text-sm font-medium text-cool-steel-900 dark:text-white mb-2">
|
||
{@label}
|
||
</span>
|
||
<textarea
|
||
id={@id}
|
||
name={@name}
|
||
aria-describedby={@errors != [] && @error_id}
|
||
aria-invalid={to_string(@errors != [])}
|
||
class={[
|
||
@class ||
|
||
"block w-full rounded-lg border-0 py-2 px-3 text-cool-steel-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-cool-steel-800 dark:text-white",
|
||
@errors == [] &&
|
||
"ring-cool-steel-300 focus:ring-cerulean-600 dark:ring-cool-steel-700 dark:focus:ring-cerulean-500",
|
||
@errors != [] &&
|
||
(@error_class ||
|
||
"ring-sweet-salmon-300 focus:ring-sweet-salmon-500 dark:ring-sweet-salmon-700 dark:focus:ring-sweet-salmon-500")
|
||
]}
|
||
{@rest}
|
||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||
</label>
|
||
<.error :if={@errors != []} id={@error_id}>
|
||
<span :for={msg <- @errors}>{msg}</span>
|
||
</.error>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||
def input(assigns) do
|
||
assigns = assign_new(assigns, :error_id, fn -> assigns.id && "#{assigns.id}-error" end)
|
||
|
||
~H"""
|
||
<div class="mb-4">
|
||
<label>
|
||
<span :if={@label} class="block text-sm font-medium text-cool-steel-900 dark:text-white mb-2">
|
||
{@label}
|
||
</span>
|
||
<input
|
||
type={@type}
|
||
name={@name}
|
||
id={@id}
|
||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||
aria-describedby={@errors != [] && @error_id}
|
||
aria-invalid={to_string(@errors != [])}
|
||
class={[
|
||
@class ||
|
||
"block w-full rounded-lg border-0 py-2 px-3 text-cool-steel-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-cool-steel-800 dark:text-white",
|
||
@errors == [] &&
|
||
"ring-cool-steel-300 focus:ring-cerulean-600 dark:ring-cool-steel-700 dark:focus:ring-cerulean-500",
|
||
@errors != [] &&
|
||
(@error_class ||
|
||
"ring-sweet-salmon-300 focus:ring-sweet-salmon-500 dark:ring-sweet-salmon-700 dark:focus:ring-sweet-salmon-500")
|
||
]}
|
||
{@rest}
|
||
/>
|
||
</label>
|
||
<.error :if={@errors != []} id={@error_id}>
|
||
<span :for={msg <- @errors}>{msg}</span>
|
||
</.error>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
# Helper used by inputs to generate form errors
|
||
defp error(assigns) do
|
||
~H"""
|
||
<p class="mt-2 flex gap-2 items-center text-sm text-sweet-salmon-600 dark:text-sweet-salmon-400">
|
||
<.icon name="hero-exclamation-circle" class="h-5 w-5 flex-shrink-0" />
|
||
{render_slot(@inner_block)}
|
||
</p>
|
||
"""
|
||
end
|
||
|
||
@doc """
|
||
Renders a header with title.
|
||
"""
|
||
slot :inner_block, required: true
|
||
slot :subtitle
|
||
slot :actions
|
||
|
||
def header(assigns) do
|
||
~H"""
|
||
<header class={[@actions != [] && "flex flex-wrap items-start justify-between gap-3", "mb-8"]}>
|
||
<div class="min-w-0">
|
||
<h1 class="text-3xl font-bold text-cool-steel-900 dark:text-white">
|
||
{render_slot(@inner_block)}
|
||
</h1>
|
||
<p :if={@subtitle != []} class="mt-2 text-sm text-cool-steel-600 dark:text-cool-steel-400">
|
||
{render_slot(@subtitle)}
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 justify-end shrink-0">{render_slot(@actions)}</div>
|
||
</header>
|
||
"""
|
||
end
|
||
|
||
@doc """
|
||
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_link, :any,
|
||
default: nil,
|
||
doc: "the function for generating a navigate path for each row, renders real <a> tags"
|
||
|
||
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-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10">
|
||
<table class="relative min-w-full divide-y divide-cool-steel-300 dark:divide-white/15">
|
||
<thead class="bg-cool-steel-50 dark:bg-cool-steel-800/75">
|
||
<tr>
|
||
<th
|
||
:for={col <- @col}
|
||
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-cool-steel-900 sm:pl-6 dark:text-cool-steel-200"
|
||
>
|
||
{col[:label]}
|
||
</th>
|
||
<th :if={@action != []} class="py-3.5 pr-4 pl-3 sm:pr-6">
|
||
<span class="sr-only">{gettext("Actions")}</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody
|
||
id={@id}
|
||
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
|
||
class="divide-y divide-cool-steel-200 bg-white dark:divide-white/10 dark:bg-cool-steel-800/50"
|
||
>
|
||
<tr
|
||
:for={row <- @rows}
|
||
id={@row_id && @row_id.(row)}
|
||
class="transition-colors hover:bg-cool-steel-50 dark:hover:bg-cool-steel-800/80"
|
||
>
|
||
<td
|
||
:for={{col, i} <- Enum.with_index(@col)}
|
||
phx-click={@row_click && @row_click.(row)}
|
||
class={[
|
||
i == 0 && "font-medium text-cool-steel-900 dark:text-white",
|
||
i > 0 && "text-cool-steel-500 dark:text-cool-steel-400",
|
||
@row_link && "p-0 text-sm whitespace-nowrap",
|
||
!@row_link && "py-4 pr-3 pl-4 text-sm whitespace-nowrap sm:pl-6",
|
||
@row_click && "cursor-pointer"
|
||
]}
|
||
>
|
||
<%= if @row_link do %>
|
||
<.link
|
||
navigate={@row_link.(row)}
|
||
class="block py-4 pr-3 pl-4 sm:pl-6 text-inherit no-underline"
|
||
>
|
||
{render_slot(col, @row_item.(row))}
|
||
</.link>
|
||
<% else %>
|
||
{render_slot(col, @row_item.(row))}
|
||
<% end %>
|
||
</td>
|
||
<td
|
||
:if={@action != []}
|
||
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-6"
|
||
>
|
||
<div class="flex gap-4 justify-end">
|
||
<%= for action <- @action do %>
|
||
{render_slot(action, @row_item.(row))}
|
||
<% end %>
|
||
</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="overflow-hidden bg-white shadow rounded-lg divide-y divide-cool-steel-200 dark:bg-cool-steel-800/50 dark:divide-white/10">
|
||
<div :for={item <- @item} class="px-6 py-4">
|
||
<dt class="text-sm font-medium text-cool-steel-900 dark:text-white">{item.title}</dt>
|
||
<dd class="mt-1 text-sm text-cool-steel-700 dark:text-cool-steel-300">{render_slot(item)}</dd>
|
||
</div>
|
||
</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 `assets/vendor/heroicons.js`.
|
||
|
||
## Examples
|
||
|
||
<.icon name="hero-x-mark" />
|
||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||
"""
|
||
attr :name, :string, required: true
|
||
attr :class, :any, default: "size-4"
|
||
|
||
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 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 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
|
||
|
||
@doc """
|
||
Renders pagination controls with page numbers and ellipsis.
|
||
|
||
Supports both mobile (Previous/Next only) and desktop (full page numbers) views.
|
||
|
||
## Examples
|
||
|
||
<.pagination
|
||
meta={@pagination}
|
||
path={~p"/admin"}
|
||
/>
|
||
|
||
<.pagination
|
||
meta={@pagination}
|
||
path={~p"/devices"}
|
||
params={%{"tab" => @current_tab}}
|
||
/>
|
||
|
||
"""
|
||
attr :meta, :map, required: true, doc: "Pagination metadata with :page, :per_page, :total_count, :total_pages"
|
||
attr :path, :string, required: true, doc: "Base path for pagination links"
|
||
attr :params, :map, default: %{}, doc: "Additional query params to preserve"
|
||
|
||
def pagination(assigns) do
|
||
~H"""
|
||
<%= if @meta.total_pages > 1 do %>
|
||
<div class="mt-6 flex items-center justify-between border-t border-cool-steel-200 dark:border-white/10 pt-6">
|
||
<!-- Mobile: Previous/Next only -->
|
||
<div class="flex flex-1 justify-between sm:hidden">
|
||
<.link
|
||
:if={@meta.page > 1}
|
||
patch={build_pagination_path(@path, @params, @meta.page - 1)}
|
||
class="relative inline-flex items-center rounded-md border border-cool-steel-300 bg-white px-4 py-2 text-sm font-medium text-cool-steel-700 hover:bg-cool-steel-50 dark:border-white/10 dark:bg-cool-steel-800 dark:text-cool-steel-300 dark:hover:bg-cool-steel-700"
|
||
>
|
||
Previous
|
||
</.link>
|
||
<.link
|
||
:if={@meta.page < @meta.total_pages}
|
||
patch={build_pagination_path(@path, @params, @meta.page + 1)}
|
||
class="relative ml-3 inline-flex items-center rounded-md border border-cool-steel-300 bg-white px-4 py-2 text-sm font-medium text-cool-steel-700 hover:bg-cool-steel-50 dark:border-white/10 dark:bg-cool-steel-800 dark:text-cool-steel-300 dark:hover:bg-cool-steel-700"
|
||
>
|
||
Next
|
||
</.link>
|
||
</div>
|
||
<!-- Desktop: Full pagination with page numbers -->
|
||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||
<div>
|
||
<p class="text-sm text-cool-steel-700 dark:text-cool-steel-400">
|
||
Showing <span class="font-medium">{(@meta.page - 1) * @meta.per_page + 1}</span>
|
||
to
|
||
<span class="font-medium">{min(@meta.page * @meta.per_page, @meta.total_count)}</span>
|
||
of <span class="font-medium">{@meta.total_count}</span>
|
||
results
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||
<!-- Previous button -->
|
||
<.link
|
||
:if={@meta.page > 1}
|
||
patch={build_pagination_path(@path, @params, @meta.page - 1)}
|
||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-cool-steel-400 ring-1 ring-inset ring-cool-steel-300 hover:bg-cool-steel-50 dark:ring-white/10 dark:hover:bg-cool-steel-800"
|
||
>
|
||
<span class="sr-only">Previous</span>
|
||
<.icon name="hero-chevron-left" class="h-5 w-5" />
|
||
</.link>
|
||
<!-- Page numbers with ellipsis -->
|
||
<%= for page_num <- pagination_range(@meta.page, @meta.total_pages) do %>
|
||
<%= if page_num == :ellipsis do %>
|
||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-cool-steel-700 ring-1 ring-inset ring-cool-steel-300 dark:text-cool-steel-400 dark:ring-white/10">
|
||
...
|
||
</span>
|
||
<% else %>
|
||
<.link
|
||
patch={build_pagination_path(@path, @params, page_num)}
|
||
class={[
|
||
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-cool-steel-300 dark:ring-white/10",
|
||
if @meta.page == page_num do
|
||
"z-10 bg-cerulean-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cerulean-600"
|
||
else
|
||
"text-cool-steel-900 hover:bg-cool-steel-50 dark:text-cool-steel-300 dark:hover:bg-cool-steel-800"
|
||
end
|
||
]}
|
||
>
|
||
{page_num}
|
||
</.link>
|
||
<% end %>
|
||
<% end %>
|
||
<!-- Next button -->
|
||
<.link
|
||
:if={@meta.page < @meta.total_pages}
|
||
patch={build_pagination_path(@path, @params, @meta.page + 1)}
|
||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-cool-steel-400 ring-1 ring-inset ring-cool-steel-300 hover:bg-cool-steel-50 dark:ring-white/10 dark:hover:bg-cool-steel-800"
|
||
>
|
||
<span class="sr-only">Next</span>
|
||
<.icon name="hero-chevron-right" class="h-5 w-5" />
|
||
</.link>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
"""
|
||
end
|
||
|
||
# Generate pagination range with ellipsis for large page counts
|
||
# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
|
||
defp pagination_range(current_page, total_pages) do
|
||
cond do
|
||
total_pages <= 7 ->
|
||
# Show all pages if 7 or fewer
|
||
Enum.to_list(1..total_pages)
|
||
|
||
current_page <= 4 ->
|
||
# Near the start
|
||
[1, 2, 3, 4, 5, :ellipsis, total_pages]
|
||
|
||
current_page >= total_pages - 3 ->
|
||
# Near the end
|
||
[
|
||
1,
|
||
:ellipsis,
|
||
total_pages - 4,
|
||
total_pages - 3,
|
||
total_pages - 2,
|
||
total_pages - 1,
|
||
total_pages
|
||
]
|
||
|
||
true ->
|
||
# In the middle
|
||
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
|
||
end
|
||
end
|
||
|
||
# Build pagination path with preserved query params
|
||
defp build_pagination_path(base_path, params, page) do
|
||
query_params = Map.put(params, "page", page)
|
||
query_string = URI.encode_query(query_params)
|
||
|
||
if query_string == "" do
|
||
base_path
|
||
else
|
||
"#{base_path}?#{query_string}"
|
||
end
|
||
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(ToweropsWeb.Gettext, "errors", msg, msg, count, opts)
|
||
else
|
||
Gettext.dgettext(ToweropsWeb.Gettext, "errors", msg, opts)
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Renders a relative timestamp with full timestamp on hover.
|
||
|
||
## Examples
|
||
|
||
<.timestamp datetime={@device.inserted_at} timezone={@timezone} />
|
||
|
||
"""
|
||
attr :datetime, :any, required: true, doc: "DateTime to display"
|
||
attr :timezone, :string, default: "UTC", doc: "User's timezone for full timestamp"
|
||
attr :class, :string, default: "", doc: "Additional CSS classes"
|
||
attr :format, :string, default: "relative", doc: "Display format: 'relative' or 'absolute'"
|
||
attr :now, :any, default: nil, doc: "Current time for live-ticking relative timestamps"
|
||
|
||
def timestamp(assigns) do
|
||
assigns =
|
||
assigns
|
||
|> assign(:relative_time, ToweropsWeb.TimeHelpers.format_time_ago(assigns.datetime))
|
||
|> assign(:full_time, ToweropsWeb.TimeHelpers.format_datetime(assigns.datetime, assigns.timezone))
|
||
|
||
~H"""
|
||
<span class={@class} title={if @format == "relative", do: @full_time, else: @relative_time}>
|
||
{if @format == "relative", do: @relative_time, else: @full_time}
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
@doc """
|
||
Renders a signal strength badge with color-coded quality indicator.
|
||
|
||
## Thresholds (dBm)
|
||
- Excellent: -55 or higher (green)
|
||
- Good: -56 to -65 (blue)
|
||
- Fair: -66 to -75 (yellow)
|
||
- Poor: -76 to -85 (orange)
|
||
- Critical: below -85 (red)
|
||
|
||
## Examples
|
||
|
||
<.signal_badge value={-60} />
|
||
<.signal_badge value={nil} />
|
||
"""
|
||
attr :value, :integer, doc: "Signal strength in dBm"
|
||
|
||
def signal_badge(%{value: nil} = assigns) do
|
||
~H"""
|
||
<span class="inline-flex items-center rounded-md bg-cool-steel-50 px-2 py-1 text-xs font-medium text-cool-steel-600 ring-1 ring-cool-steel-500/10 ring-inset dark:bg-cool-steel-400/10 dark:text-cool-steel-400 dark:ring-cool-steel-400/20">
|
||
—
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
def signal_badge(assigns) do
|
||
{color, label} =
|
||
cond do
|
||
assigns.value >= -55 -> {"green", "Excellent"}
|
||
assigns.value >= -65 -> {"blue", "Good"}
|
||
assigns.value >= -75 -> {"yellow", "Fair"}
|
||
assigns.value >= -85 -> {"orange", "Poor"}
|
||
true -> {"red", "Critical"}
|
||
end
|
||
|
||
assigns = assign(assigns, color: color, label: label)
|
||
|
||
~H"""
|
||
<span
|
||
class={[
|
||
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||
@color == "green" &&
|
||
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20",
|
||
@color == "blue" &&
|
||
"bg-cerulean-50 text-cerulean-700 ring-cerulean-600/20 dark:bg-cerulean-400/10 dark:text-cerulean-400 dark:ring-cerulean-400/20",
|
||
@color == "yellow" &&
|
||
"bg-wheat-50 text-wheat-800 ring-wheat-600/20 dark:bg-wheat-400/10 dark:text-wheat-500 dark:ring-wheat-400/20",
|
||
@color == "orange" &&
|
||
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20",
|
||
@color == "red" &&
|
||
"bg-sweet-salmon-50 text-sweet-salmon-700 ring-sweet-salmon-600/10 dark:bg-sweet-salmon-400/10 dark:text-sweet-salmon-400 dark:ring-sweet-salmon-400/20"
|
||
]}
|
||
title={@label}
|
||
>
|
||
{@value} dBm
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
@doc """
|
||
Renders a Signal-to-Noise Ratio (SNR) badge with color-coded quality indicator.
|
||
|
||
## Thresholds (dB)
|
||
- Excellent: 35+ (green)
|
||
- Good: 25-34 (blue)
|
||
- Fair: 15-24 (yellow)
|
||
- Poor: 10-14 (orange)
|
||
- Critical: below 10 (red)
|
||
|
||
## Examples
|
||
|
||
<.snr_badge value={30} />
|
||
<.snr_badge value={nil} />
|
||
"""
|
||
attr :value, :integer, doc: "SNR in dB"
|
||
|
||
def snr_badge(%{value: nil} = assigns) do
|
||
~H"""
|
||
<span class="inline-flex items-center rounded-md bg-cool-steel-50 px-2 py-1 text-xs font-medium text-cool-steel-600 ring-1 ring-cool-steel-500/10 ring-inset dark:bg-cool-steel-400/10 dark:text-cool-steel-400 dark:ring-cool-steel-400/20">
|
||
—
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
def snr_badge(assigns) do
|
||
{color, label} =
|
||
cond do
|
||
assigns.value >= 35 -> {"green", "Excellent"}
|
||
assigns.value >= 25 -> {"blue", "Good"}
|
||
assigns.value >= 15 -> {"yellow", "Fair"}
|
||
assigns.value >= 10 -> {"orange", "Poor"}
|
||
true -> {"red", "Critical"}
|
||
end
|
||
|
||
assigns = assign(assigns, color: color, label: label)
|
||
|
||
~H"""
|
||
<span
|
||
class={[
|
||
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||
@color == "green" &&
|
||
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20",
|
||
@color == "blue" &&
|
||
"bg-cerulean-50 text-cerulean-700 ring-cerulean-600/20 dark:bg-cerulean-400/10 dark:text-cerulean-400 dark:ring-cerulean-400/20",
|
||
@color == "yellow" &&
|
||
"bg-wheat-50 text-wheat-800 ring-wheat-600/20 dark:bg-wheat-400/10 dark:text-wheat-500 dark:ring-wheat-400/20",
|
||
@color == "orange" &&
|
||
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20",
|
||
@color == "red" &&
|
||
"bg-sweet-salmon-50 text-sweet-salmon-700 ring-sweet-salmon-600/10 dark:bg-sweet-salmon-400/10 dark:text-sweet-salmon-400 dark:ring-sweet-salmon-400/20"
|
||
]}
|
||
title={@label}
|
||
>
|
||
{@value} dB
|
||
</span>
|
||
"""
|
||
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
|