aprs.me/lib/aprsme_web/components/core_components.ex
Graham McIntire fc5e91479b
refactor: pattern-match CoreComponents.safe_row_id + CallsignController
- CoreComponents.safe_row_id: four function heads dispatch on the row
  shape (atom-key map, string-key map, other map fallback, non-map)
  instead of a cond with Map.has_key? checks.
- Api.V1.CallsignController.validate_callsign: check_callsign_format/2
  splits the validation success/failure branches into two clauses; the
  binary-vs-everything-else dispatch stays in validate_callsign/1.

Adds 5 integration tests for the CallsignController covering invalid,
not-found, success, case-normalization, and length-limit paths. The
test file is async:false because it shares the rate-limiter ETS with
other API tests and could otherwise trip the 100/min per-IP cap.
2026-04-23 14:18:30 -05:00

943 lines
33 KiB
Elixir

defmodule AprsmeWeb.CoreComponents do
@moduledoc """
Provides core UI components.
The components in this module use Tailwind CSS, a utility-first CSS framework.
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
customize the generated components in this module.
Icons are provided by [heroicons](https://heroicons.com), vendored in priv/heroicons/.
"""
use Phoenix.Component
use Gettext, backend: AprsmeWeb.Gettext
alias Phoenix.HTML.Form
alias Phoenix.LiveView.JS
@doc """
Renders a Heroicon SVG inline from priv/heroicons/.
SVGs are loaded from trusted vendored heroicons directory and are safe to render as raw HTML.
Usage: <.icon name="arrow-left" outline={true} class="h-5 w-5" />
"""
attr :name, :string, required: true
attr :outline, :boolean, default: true
attr :class, :string, default: nil
def icon(%{name: name, outline: outline, class: class} = assigns) do
style = if outline, do: "outline", else: "solid"
path =
Path.join([
:aprsme |> :code.priv_dir() |> to_string(),
"heroicons/24",
style,
name <> ".svg"
])
svg =
case File.read(path) do
{:ok, contents} ->
# Insert class attribute if not present
# SVG is from trusted vendored heroicons, safe to modify and render
Regex.replace(~r/<svg([^>]*?)>/, contents, fn _, attrs ->
add_class_to_svg_tag(attrs, class)
end)
_ ->
"<!-- icon not found: #{name} -->"
end
assigns = assign(assigns, :svg, Phoenix.HTML.raw(svg))
~H"""
{@svg}
"""
end
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
Are you sure?
<:confirm>OK</:confirm>
<:cancel>Cancel</:cancel>
</.modal>
JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
for the caller to react to each button press, for example:
<.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
Are you sure you?
<:confirm>OK</:confirm>
<:cancel>Cancel</:cancel>
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
attr :on_confirm, JS, default: %JS{}
slot :inner_block, required: true
slot :title
slot :subtitle
slot :confirm
slot :cancel
def modal(assigns) do
~H"""
<div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden">
<div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 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-mounted={@show && show_modal(@id)}
phx-window-keydown={hide_modal(@on_cancel, @id)}
phx-key="escape"
phx-click-away={hide_modal(@on_cancel, @id)}
class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition"
>
<div class="absolute top-6 right-5">
<button
phx-click={hide_modal(@on_cancel, @id)}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label={gettext("close")}
>
<.icon name="x-mark" outline={false} class="h-5 w-5 stroke-current" />
</button>
</div>
<div id={"#{@id}-content"}>
<header :if={@title != []}>
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
{render_slot(@title)}
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
{render_slot(@subtitle)}
</p>
</header>
{render_slot(@inner_block)}
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
<.button
:for={confirm <- @confirm}
id={"#{@id}-confirm"}
phx-click={@on_confirm}
phx-disable-with
class="py-2 px-3"
>
{render_slot(confirm)}
</.button>
<.link
:for={cancel <- @cancel}
phx-click={hide_modal(@on_cancel, @id)}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
{render_slot(cancel)}
</.link>
</div>
</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, default: "flash", 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 :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
attr :close, :boolean, default: true, doc: "whether the flash can be closed"
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
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-mounted={@autoshow && show("##{@id}")}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("#flash")}
role="alert"
class={[
"fixed hidden top-2 right-2 w-80 sm:w-96 z-[9999] rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
@kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
{@rest}
>
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
<.icon name="information-circle" outline={true} class="h-4 w-4" />
<.icon name="exclamation-circle" outline={true} class="h-4 w-4" />
{@title}
</p>
<p class="mt-2 text-[0.8125rem] leading-5">{msg}</p>
<button :if={@close} type="button" class="group absolute top-2 right-1 p-2" aria-label={gettext("close")}>
<.icon name="x-mark" outline={false} class="h-5 w-5 stroke-current opacity-40 group-hover:opacity-70" />
</button>
</div>
"""
end
@doc """
Renders a simple form.
## Examples
<.simple_form :let={f} for={%{}} as={:user} phx-change="validate" phx-submit="save">
<.input field={{f, :email}} label="Email"/>
<.input field={{f, :username}} label="Username" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, default: nil, doc: "the datastructure 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),
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="space-y-8 mt-10">
{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.Form{}` and field name may be passed to the input
to build input names and error messages, or all the attributes and
errors may be passed explicitly.
## Examples
<.input field={{f, :email}} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any
attr :name, :any
attr :label, :string, default: nil
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
range radio search select tel text textarea time url week)
attr :value, :any
attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}"
attr :errors, :list
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(autocomplete disabled form max maxlength min minlength
pattern placeholder readonly required size step)
slot :inner_block
def input(%{field: {f, field}} = assigns) do
assigns
|> assign(field: nil)
|> assign_new(:name, fn ->
name = Form.input_name(f, field)
if assigns.multiple, do: name <> "[]", else: name
end)
|> assign_new(:id, fn -> Form.input_id(f, field) end)
|> assign_new(:value, fn -> Form.input_value(f, field) end)
|> assign_new(:errors, fn -> translate_errors(f.errors || [], field) end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns = assign_new(assigns, :checked, fn -> input_equals?(assigns.value, "true") end)
~H"""
<label phx-feedback-for={@name} class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id || @name}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900"
{@rest}
/>
{@label}
</label>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<select
id={@id}
name={@name}
class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt}>{@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 phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<textarea
id={@id || @name}
name={@name}
class={[
input_border(@errors),
"mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
"text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5"
]}
{@rest}
>
<%= @value %></textarea>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<input
type={@type}
name={@name}
id={@id || @name}
value={@value}
class={[
input_border(@errors),
"mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
"text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5"
]}
{@rest}
/>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
defp input_border([] = _errors), do: "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5"
defp input_border([_ | _] = _errors), do: "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
@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="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-rose-600">
<.icon name="exclamation-circle" outline={true} class="mt-0.5 h-5 w-5 flex-none fill-rose-500" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a modern site header with navigation links.
"""
attr :class, :string, default: ""
attr :dev_mode, :boolean, default: false
attr :current_user, :any, default: nil
def header(assigns) do
~H"""
<header class="site-header relative bg-gray-800 dark:bg-gray-800/50 dark:after:pointer-events-none dark:after:absolute dark:after:inset-x-0 dark:after:bottom-0 dark:after:h-px dark:after:bg-white/10">
<nav class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center">
<.link
navigate="/"
class="flex items-center gap-2 text-xl font-bold text-white hover:text-gray-200"
>
<svg class="h-6 w-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
aprs.me
<%= if @dev_mode do %>
<span class="ml-1 inline-flex items-center rounded-md bg-yellow-400/10 px-2 py-0.5 text-xs font-medium text-yellow-500 ring-1 ring-inset ring-yellow-400/20">
DEV
</span>
<% end %>
</.link>
</div>
<div class="hidden lg:flex lg:items-center lg:gap-x-1">
<.navigation variant={:horizontal} current_user={@current_user} />
</div>
<div class="flex items-center gap-2">
<.theme_selector />
<div class="relative lg:hidden">
<button
phx-click={
JS.toggle(to: "#mobile-menu", in: "transition ease-out duration-100", out: "transition ease-in duration-75")
}
class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-white/5 hover:text-white focus:outline-2 focus:-outline-offset-1 focus:outline-indigo-500"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<div
id="mobile-menu"
class="hidden absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-gray-800 py-2 shadow-lg ring-1 ring-white/10"
>
<.navigation variant={:vertical} current_user={@current_user} />
</div>
</div>
</div>
</nav>
</header>
"""
end
@doc """
Renders a theme selector dropdown.
"""
def theme_selector(assigns) do
~H"""
<div class="relative">
<button
phx-click={
JS.toggle(to: "#theme-menu", in: "transition ease-out duration-100", out: "transition ease-in duration-75")
}
class="relative rounded-full p-1.5 text-gray-400 hover:text-white focus:outline-2 focus:outline-offset-2 focus:outline-indigo-500"
>
<svg class="h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
</svg>
</button>
<div
id="theme-menu"
phx-click-away={JS.hide(to: "#theme-menu")}
class="hidden absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-white/10"
>
<button
phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "light"}) |> JS.hide(to: "#theme-menu")}
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-gray-200 hover:bg-white/5"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>
{gettext("Light")}
</button>
<button
phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "dark"}) |> JS.hide(to: "#theme-menu")}
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-gray-200 hover:bg-white/5"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
{gettext("Dark")}
</button>
<button
phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "auto"}) |> JS.hide(to: "#theme-menu")}
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-gray-200 hover:bg-white/5"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
{gettext("Auto")}
</button>
</div>
</div>
"""
end
# Helper function to safely generate row IDs.
# Prefer the :id / "id" field on maps; hash the inspected row as a
# last-resort fallback. Non-map rows go through Phoenix.Param.
defp safe_row_id(%{id: id}), do: id
defp safe_row_id(%{"id" => id}), do: id
defp safe_row_id(row) when is_map(row) do
:md5 |> :crypto.hash(inspect(row)) |> Base.encode16() |> String.slice(0, 8)
end
defp safe_row_id(row), do: Phoenix.Param.to_param(row)
@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 :row_click, :any, default: nil
attr :rows, :list, required: true
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
~H"""
<div id={@id} class="overflow-y-auto px-0 sm:overflow-visible sm:px-0">
<table class="w-[40rem] sm:w-full">
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
<th class="relative p-0 pb-4"><span class="sr-only">{gettext("Actions")}</span></th>
</tr>
</thead>
<tbody class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700">
<tr :for={row <- @rows} id={"#{@id}-#{safe_row_id(row)}"} class="relative group hover:bg-zinc-50">
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["p-0", @row_click && "hover:cursor-pointer"]}
>
<div :if={i == 0}>
<span class="absolute h-full w-4 top-0 left-0 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class="absolute h-full w-4 top-0 -right-4 group-hover:bg-zinc-50 sm:rounded-r-xl" />
</div>
<div class="block py-4 pr-6">
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
{render_slot(col, row)}
</span>
</div>
</td>
<td :if={@action != []} class="p-0 w-14">
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
{render_slot(action, 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 sm:gap-8">
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500">{item.title}</dt>
<dd class="text-sm leading-6 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="arrow-left" outline={false} class="w-3 h-3 stroke-current inline" />
{render_slot(@inner_block)}
</.link>
</div>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
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",
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> 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.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 "is invalid" in the "errors" domain
# dgettext("errors", "is invalid")
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
# This requires us to call the Gettext module passing our gettext
# backend as first argument.
#
# Note we use the "errors" domain, which means translations
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(AprsmeWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(AprsmeWeb.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
defp input_equals?(val1, val2) do
Phoenix.HTML.html_escape(val1) == Phoenix.HTML.html_escape(val2)
end
@doc """
Renders a navigation menu component that can be used in both regular pages and map sidebars.
"""
attr :class, :string, default: ""
attr :variant, :atom, values: [:horizontal, :vertical, :sidebar], default: :horizontal
attr :current_user, :any, default: nil
attr :map_state, :map, default: nil
attr :tracked_callsign, :string, default: ""
def navigation(assigns) do
# Build home URL with map state if available
home_url =
if assigns[:map_state] do
base_url = "/?lat=#{assigns.map_state.lat}&lng=#{assigns.map_state.lng}&z=#{assigns.map_state.zoom}"
if assigns[:tracked_callsign] && assigns.tracked_callsign != "" do
base_url <> "&call=#{assigns.tracked_callsign}"
else
base_url
end
else
"/"
end
assigns = assign(assigns, :home_url, home_url)
~H"""
<%= if @variant == :horizontal do %>
<.link
navigate={@home_url}
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Home")}
</.link>
<.link
navigate="/api"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
API
</.link>
<.link
navigate="/about"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("About")}
</.link>
<%= if @current_user do %>
<.link
navigate="/users/settings"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Settings")}
</.link>
<.link
href="/users/log_out"
method="delete"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Log out")}
</.link>
<% else %>
<.link
navigate="/users/register"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Register")}
</.link>
<.link
navigate="/users/log_in"
class="rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-400"
>
{gettext("Log in")}
</.link>
<% end %>
<% end %>
<%= if @variant == :vertical do %>
<.link
navigate={@home_url}
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Home")}
</.link>
<.link
navigate="/api"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
API
</.link>
<.link
navigate="/about"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("About")}
</.link>
<%= if @current_user do %>
<.link
navigate="/users/settings"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Settings")}
</.link>
<.link
href="/users/log_out"
method="delete"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Log out")}
</.link>
<% else %>
<.link
navigate="/users/register"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-white/5 hover:text-white"
>
{gettext("Register")}
</.link>
<.link
navigate="/users/log_in"
class="block rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-400"
>
{gettext("Log in")}
</.link>
<% end %>
<% end %>
<%= if @variant == :sidebar do %>
<.link
navigate={@home_url}
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
{gettext("Home")}
</.link>
<.link
navigate="/api"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
API
</.link>
<.link
navigate="/about"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
{gettext("About")}
</.link>
<%= if @current_user do %>
<.link
navigate="/users/settings"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
{gettext("Settings")}
</.link>
<.link
href="/users/log_out"
method="delete"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
{gettext("Log out")}
</.link>
<% else %>
<.link
navigate="/users/register"
class="block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
{gettext("Register")}
</.link>
<.link
navigate="/users/log_in"
class="block rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500"
>
{gettext("Log in")}
</.link>
<% end %>
<% end %>
"""
end
defp add_class_to_svg_tag(attrs, class) do
if String.contains?(attrs, "class=") do
"<svg#{attrs}>"
else
"<svg class=\"#{class}\"#{attrs}>"
end
end
end