2026-02-18 21:23:15 +00:00
|
|
|
|
defmodule BerrypodWeb.CoreComponents do
|
2025-12-30 12:26:26 +00:00
|
|
|
|
@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.
|
|
|
|
|
|
|
2026-02-17 23:05:01 +00:00
|
|
|
|
Styled with custom admin CSS (`assets/css/admin/components.css`).
|
2025-12-30 12:26:26 +00:00
|
|
|
|
|
|
|
|
|
|
* [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
|
2026-02-18 21:23:15 +00:00
|
|
|
|
use Gettext, backend: BerrypodWeb.Gettext
|
2025-12-30 12:26:26 +00:00
|
|
|
|
|
|
|
|
|
|
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}")}
|
|
|
|
|
|
role="alert"
|
2026-03-07 09:30:07 +00:00
|
|
|
|
class="admin-banner"
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{@rest}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class={[
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
"admin-alert",
|
2026-02-17 23:05:01 +00:00
|
|
|
|
@kind == :info && "admin-alert-info",
|
|
|
|
|
|
@kind == :error && "admin-alert-error"
|
2025-12-30 12:26:26 +00:00
|
|
|
|
]}>
|
|
|
|
|
|
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
|
|
|
|
|
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
|
|
|
|
|
<div>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<p :if={@title} class="admin-alert-title">{@title}</p>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<p>{msg}</p>
|
|
|
|
|
|
</div>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<div class="admin-alert-spacer" />
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
|
<button type="button" class="admin-alert-close" aria-label={gettext("close")}>
|
|
|
|
|
|
<.icon name="hero-x-mark" class="size-5" />
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
|
@doc """
|
|
|
|
|
|
Renders inline status feedback next to a button or form section.
|
|
|
|
|
|
|
|
|
|
|
|
## Examples
|
|
|
|
|
|
|
|
|
|
|
|
<.inline_feedback status={@save_status} />
|
|
|
|
|
|
<.inline_feedback status={@save_status} message={@save_error} />
|
|
|
|
|
|
"""
|
|
|
|
|
|
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
|
|
|
|
|
|
attr :message, :string, default: nil
|
|
|
|
|
|
|
|
|
|
|
|
def inline_feedback(assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<span
|
|
|
|
|
|
:if={@status != :idle}
|
|
|
|
|
|
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
|
2026-03-08 02:10:06 +00:00
|
|
|
|
role={if(@status == :error, do: "alert", else: "status")}
|
|
|
|
|
|
aria-live={if(@status == :error, do: "assertive", else: "polite")}
|
2026-03-07 09:30:07 +00:00
|
|
|
|
>
|
|
|
|
|
|
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
|
|
|
|
|
|
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
|
|
|
|
|
|
<.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
|
|
|
|
|
|
<span>{feedback_text(@status, @message)}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
defp feedback_text(:saving, _), do: "Saving..."
|
|
|
|
|
|
defp feedback_text(:saved, nil), do: "Saved"
|
|
|
|
|
|
defp feedback_text(:saved, msg), do: msg
|
|
|
|
|
|
defp feedback_text(:error, nil), do: "Something went wrong"
|
|
|
|
|
|
defp feedback_text(:error, msg), do: msg
|
|
|
|
|
|
defp feedback_text(:idle, _), do: nil
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
@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, :string
|
2026-02-18 23:55:42 +00:00
|
|
|
|
attr :variant, :string, values: ~w(primary outline)
|
2025-12-30 12:26:26 +00:00
|
|
|
|
slot :inner_block, required: true
|
|
|
|
|
|
|
|
|
|
|
|
def button(%{rest: rest} = assigns) do
|
2026-02-18 23:55:42 +00:00
|
|
|
|
variants = %{
|
|
|
|
|
|
"primary" => "admin-btn-primary",
|
|
|
|
|
|
"outline" => "admin-btn-outline",
|
|
|
|
|
|
nil => "admin-btn-primary admin-btn-soft"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
variant_class = Map.fetch!(variants, assigns[:variant])
|
2025-12-30 12:26:26 +00:00
|
|
|
|
|
|
|
|
|
|
assigns =
|
2026-02-18 23:55:42 +00:00
|
|
|
|
assign(assigns, :class, [
|
|
|
|
|
|
"admin-btn",
|
|
|
|
|
|
variant_class,
|
|
|
|
|
|
assigns[:class]
|
|
|
|
|
|
])
|
2025-12-30 12:26:26 +00:00
|
|
|
|
|
|
|
|
|
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<.link class={@class} {@rest}>
|
|
|
|
|
|
{render_slot(@inner_block)}
|
|
|
|
|
|
</.link>
|
|
|
|
|
|
"""
|
|
|
|
|
|
else
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<button class={@class} {@rest}>
|
|
|
|
|
|
{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 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
|
|
|
|
|
|
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 :class, :string, default: nil, doc: "the input class to use over defaults"
|
|
|
|
|
|
attr :error_class, :string, 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: %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"""
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div class="admin-fieldset">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<label>
|
|
|
|
|
|
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<span class="admin-label">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id={@id}
|
|
|
|
|
|
name={@name}
|
|
|
|
|
|
value="true"
|
|
|
|
|
|
checked={@checked}
|
2026-02-17 23:05:01 +00:00
|
|
|
|
class={@class || "admin-checkbox admin-checkbox-sm"}
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{@rest}
|
|
|
|
|
|
/>{@label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<.error :for={msg <- @errors}>{msg}</.error>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
def input(%{type: "select"} = assigns) do
|
2026-03-04 12:17:56 +00:00
|
|
|
|
assigns = assign(assigns, :error_id, assigns.id && "#{assigns.id}-error")
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
~H"""
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div class="admin-fieldset">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<label>
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<span :if={@label} class="admin-label">{@label}</span>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<select
|
|
|
|
|
|
id={@id}
|
|
|
|
|
|
name={@name}
|
2026-02-17 23:05:01 +00:00
|
|
|
|
class={[@class || "admin-select", @errors != [] && (@error_class || "admin-input-error")]}
|
2025-12-30 12:26:26 +00:00
|
|
|
|
multiple={@multiple}
|
2026-03-04 12:17:56 +00:00
|
|
|
|
aria-invalid={@errors != [] && "true"}
|
|
|
|
|
|
aria-describedby={@errors != [] && @error_id}
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{@rest}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option :if={@prompt} value="">{@prompt}</option>
|
|
|
|
|
|
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
2026-03-04 12:17:56 +00:00
|
|
|
|
<.error :for={msg <- @errors} id={@error_id}>{msg}</.error>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
def input(%{type: "textarea"} = assigns) do
|
2026-03-04 12:17:56 +00:00
|
|
|
|
assigns = assign(assigns, :error_id, assigns.id && "#{assigns.id}-error")
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
~H"""
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div class="admin-fieldset">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<label>
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<span :if={@label} class="admin-label">{@label}</span>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<textarea
|
|
|
|
|
|
id={@id}
|
|
|
|
|
|
name={@name}
|
|
|
|
|
|
class={[
|
2026-02-17 23:05:01 +00:00
|
|
|
|
@class || "admin-textarea",
|
|
|
|
|
|
@errors != [] && (@error_class || "admin-input-error")
|
2025-12-30 12:26:26 +00:00
|
|
|
|
]}
|
2026-03-04 12:17:56 +00:00
|
|
|
|
aria-invalid={@errors != [] && "true"}
|
|
|
|
|
|
aria-describedby={@errors != [] && @error_id}
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{@rest}
|
|
|
|
|
|
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
|
|
|
|
|
</label>
|
2026-03-04 12:17:56 +00:00
|
|
|
|
<.error :for={msg <- @errors} id={@error_id}>{msg}</.error>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
|
|
|
|
|
def input(assigns) do
|
2026-03-04 12:17:56 +00:00
|
|
|
|
assigns = assign(assigns, :error_id, assigns.id && "#{assigns.id}-error")
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
~H"""
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div class="admin-fieldset">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<label>
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<span :if={@label} class="admin-label">{@label}</span>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<input
|
|
|
|
|
|
type={@type}
|
|
|
|
|
|
name={@name}
|
|
|
|
|
|
id={@id}
|
|
|
|
|
|
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
|
|
|
|
|
class={[
|
2026-02-17 23:05:01 +00:00
|
|
|
|
@class || "admin-input",
|
|
|
|
|
|
@errors != [] && (@error_class || "admin-input-error")
|
2025-12-30 12:26:26 +00:00
|
|
|
|
]}
|
2026-03-04 12:17:56 +00:00
|
|
|
|
aria-invalid={@errors != [] && "true"}
|
|
|
|
|
|
aria-describedby={@errors != [] && @error_id}
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{@rest}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2026-03-04 12:17:56 +00:00
|
|
|
|
<.error :for={msg <- @errors} id={@error_id}>{msg}</.error>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# Helper used by inputs to generate form errors
|
2026-03-04 12:17:56 +00:00
|
|
|
|
attr :id, :string, default: nil
|
|
|
|
|
|
slot :inner_block, required: true
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
defp error(assigns) do
|
|
|
|
|
|
~H"""
|
2026-03-04 12:17:56 +00:00
|
|
|
|
<p class="admin-error" id={@id} role="alert">
|
|
|
|
|
|
<.icon name="hero-exclamation-circle" class="admin-error-icon" />
|
2026-03-05 15:29:05 +00:00
|
|
|
|
<strong>{render_slot(@inner_block)}</strong>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
|
Renders a header with title.
|
|
|
|
|
|
"""
|
|
|
|
|
|
slot :inner_block, required: true
|
|
|
|
|
|
slot :subtitle
|
|
|
|
|
|
slot :actions
|
|
|
|
|
|
|
|
|
|
|
|
def header(assigns) do
|
|
|
|
|
|
~H"""
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<header class={[
|
|
|
|
|
|
"admin-header",
|
|
|
|
|
|
@actions != [] && "admin-header-with-actions"
|
|
|
|
|
|
]}>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<div>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<h1 class="admin-header-title">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{render_slot(@inner_block)}
|
|
|
|
|
|
</h1>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<p :if={@subtitle != []} class="admin-header-subtitle">
|
2025-12-30 12:26:26 +00:00
|
|
|
|
{render_slot(@subtitle)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<div class="admin-header-actions">{render_slot(@actions)}</div>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
</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_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"""
|
2026-03-01 09:42:34 +00:00
|
|
|
|
<div class="admin-table-wrap">
|
|
|
|
|
|
<table class="admin-table admin-table-zebra">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th :for={col <- @col}>{col[:label]}</th>
|
|
|
|
|
|
<th :if={@action != []}>
|
|
|
|
|
|
<span class="sr-only">{gettext("Actions")}</span>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
|
|
|
|
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
|
|
|
|
|
<td
|
|
|
|
|
|
:for={col <- @col}
|
|
|
|
|
|
phx-click={@row_click && @row_click.(row)}
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
|
class={@row_click && "admin-table-row-clickable"}
|
2026-03-01 09:42:34 +00:00
|
|
|
|
>
|
|
|
|
|
|
{render_slot(col, @row_item.(row))}
|
|
|
|
|
|
</td>
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<td :if={@action != []} class="admin-table-actions">
|
|
|
|
|
|
<div class="admin-table-actions-row">
|
2026-03-01 09:42:34 +00:00
|
|
|
|
<%= for action <- @action do %>
|
|
|
|
|
|
{render_slot(action, @row_item.(row))}
|
|
|
|
|
|
<% end %>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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"""
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<ul class="admin-list">
|
|
|
|
|
|
<li :for={item <- @item} class="admin-list-row">
|
|
|
|
|
|
<div class="admin-list-grow">
|
refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.
Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00
|
|
|
|
<div class="admin-list-title">{item.title}</div>
|
2025-12-30 12:26:26 +00:00
|
|
|
|
<div>{render_slot(item)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
"""
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-02-18 01:15:28 +00:00
|
|
|
|
Icons are extracted from the `deps/heroicons` directory and bundled into
|
|
|
|
|
|
`admin/icons.css` by `mix generate_admin_icons`.
|
2025-12-30 12:26:26 +00:00
|
|
|
|
|
|
|
|
|
|
## 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, :string, default: "size-4"
|
|
|
|
|
|
|
|
|
|
|
|
def icon(%{name: "hero-" <> _} = assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<span class={[@name, @class]} />
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
add external link UX: icons, rel attributes, screen reader labels
New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:09 +00:00
|
|
|
|
@doc """
|
|
|
|
|
|
Renders a link to an external site with proper security attributes,
|
|
|
|
|
|
an external-link icon, and screen reader context.
|
2026-03-04 07:12:25 +00:00
|
|
|
|
|
|
|
|
|
|
Set `icon={false}` when the link already contains its own visual indicator.
|
add external link UX: icons, rel attributes, screen reader labels
New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:09 +00:00
|
|
|
|
"""
|
|
|
|
|
|
attr :href, :string, required: true
|
|
|
|
|
|
attr :class, :string, default: nil
|
2026-03-04 07:12:25 +00:00
|
|
|
|
attr :icon, :boolean, default: true
|
add external link UX: icons, rel attributes, screen reader labels
New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:09 +00:00
|
|
|
|
attr :rest, :global
|
|
|
|
|
|
slot :inner_block, required: true
|
|
|
|
|
|
|
|
|
|
|
|
def external_link(assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<a href={@href} target="_blank" rel="noopener noreferrer" class={@class} {@rest}>
|
|
|
|
|
|
{render_slot(@inner_block)}
|
2026-03-04 07:12:25 +00:00
|
|
|
|
<.icon :if={@icon} name="hero-arrow-top-right-on-square" class="external-link-icon" />
|
add external link UX: icons, rel attributes, screen reader labels
New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:09 +00:00
|
|
|
|
<span class="sr-only">(opens in new tab)</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
|
## 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 """
|
|
|
|
|
|
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
|
2026-02-18 21:23:15 +00:00
|
|
|
|
Gettext.dngettext(BerrypodWeb.Gettext, "errors", msg, msg, count, opts)
|
2025-12-30 12:26:26 +00:00
|
|
|
|
else
|
2026-02-18 21:23:15 +00:00
|
|
|
|
Gettext.dgettext(BerrypodWeb.Gettext, "errors", msg, opts)
|
2025-12-30 12:26:26 +00:00
|
|
|
|
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
|
2026-01-31 22:08:34 +00:00
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
|
Renders a modal dialog.
|
|
|
|
|
|
|
|
|
|
|
|
Uses daisyUI's modal component with proper accessibility.
|
|
|
|
|
|
|
|
|
|
|
|
## Examples
|
|
|
|
|
|
|
|
|
|
|
|
<.modal id="confirm-modal">
|
|
|
|
|
|
Are you sure?
|
|
|
|
|
|
<:actions>
|
|
|
|
|
|
<button class="btn">Cancel</button>
|
|
|
|
|
|
<button class="btn btn-primary">Confirm</button>
|
|
|
|
|
|
</:actions>
|
|
|
|
|
|
</.modal>
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
attr :id, :string, required: true
|
|
|
|
|
|
attr :show, :boolean, default: false
|
|
|
|
|
|
attr :on_cancel, JS, default: %JS{}
|
|
|
|
|
|
|
|
|
|
|
|
slot :inner_block, required: true
|
|
|
|
|
|
slot :actions
|
|
|
|
|
|
|
|
|
|
|
|
def modal(assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<dialog
|
|
|
|
|
|
id={@id}
|
2026-02-17 23:05:01 +00:00
|
|
|
|
class="admin-modal"
|
2026-01-31 22:08:34 +00:00
|
|
|
|
phx-mounted={@show && show_modal(@id)}
|
|
|
|
|
|
phx-remove={hide_modal(@id)}
|
|
|
|
|
|
>
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div class="admin-modal-box">
|
|
|
|
|
|
<form method="dialog" class="admin-modal-close">
|
2026-01-31 22:08:34 +00:00
|
|
|
|
<button
|
2026-02-17 23:05:01 +00:00
|
|
|
|
class="admin-btn admin-btn-ghost admin-btn-icon-round admin-btn-sm"
|
2026-01-31 22:08:34 +00:00
|
|
|
|
phx-click={@on_cancel}
|
|
|
|
|
|
aria-label={gettext("close")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<.icon name="hero-x-mark" class="size-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{render_slot(@inner_block)}
|
2026-02-17 23:05:01 +00:00
|
|
|
|
<div :if={@actions != []} class="admin-modal-actions">
|
2026-01-31 22:08:34 +00:00
|
|
|
|
{render_slot(@actions)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-02-21 19:29:34 +00:00
|
|
|
|
@doc """
|
|
|
|
|
|
Renders a radio card group — a set of selectable cards backed by radio inputs.
|
|
|
|
|
|
|
|
|
|
|
|
Each option is a map with `:value` and `:name`, plus optional `:description`,
|
|
|
|
|
|
`:tags`, `:url`, `:badge`, and `:disabled` keys.
|
|
|
|
|
|
|
|
|
|
|
|
The `display` attr controls card content layout:
|
|
|
|
|
|
- `:tags` (default) — name + tag pills + short description
|
|
|
|
|
|
- `:description` — name + description text + link
|
|
|
|
|
|
|
|
|
|
|
|
## Examples
|
|
|
|
|
|
|
|
|
|
|
|
<.card_radio_group
|
|
|
|
|
|
name="email[adapter]"
|
|
|
|
|
|
value={@selected}
|
|
|
|
|
|
legend="Email provider"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
%{value: "postmark", name: "Postmark", description: "Fast email.", tags: ["Transactional", "US"]},
|
|
|
|
|
|
%{value: "smtp", name: "SMTP", description: "Any SMTP server.", tags: ["Any type"]}
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
"""
|
|
|
|
|
|
attr :name, :string, required: true
|
|
|
|
|
|
attr :value, :string, default: nil
|
|
|
|
|
|
attr :legend, :string, required: true
|
|
|
|
|
|
attr :options, :list, required: true
|
|
|
|
|
|
attr :disabled, :boolean, default: false
|
|
|
|
|
|
attr :display, :atom, default: :tags, values: [:description, :tags]
|
|
|
|
|
|
|
|
|
|
|
|
def card_radio_group(assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<fieldset class="card-radio-fieldset" disabled={@disabled}>
|
|
|
|
|
|
<legend class="admin-label">{@legend}</legend>
|
|
|
|
|
|
<div class="card-radio-grid">
|
|
|
|
|
|
<label
|
|
|
|
|
|
:for={option <- @options}
|
|
|
|
|
|
class={[
|
|
|
|
|
|
"card-radio-card",
|
|
|
|
|
|
@value == option.value && "card-radio-card-selected",
|
|
|
|
|
|
option[:disabled] && "card-radio-card-disabled"
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id={"#{@name}-#{option.value}"}
|
|
|
|
|
|
name={@name}
|
|
|
|
|
|
value={option.value}
|
|
|
|
|
|
checked={@value == option.value}
|
|
|
|
|
|
disabled={option[:disabled] || @disabled}
|
|
|
|
|
|
class="card-radio-input"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="card-radio-name">{option.name}</span>
|
|
|
|
|
|
<.card_radio_content option={option} display={@display} />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</fieldset>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
attr :option, :map, required: true
|
|
|
|
|
|
attr :display, :atom, required: true
|
|
|
|
|
|
|
|
|
|
|
|
defp card_radio_content(%{display: :tags} = assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<span :if={@option[:tags]} class="card-radio-tags">
|
|
|
|
|
|
<span :for={tag <- @option.tags} class="card-radio-tag">{tag}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span :if={@option[:description]} class="card-radio-description">
|
|
|
|
|
|
{@option.description}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
defp card_radio_content(assigns) do
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<span :if={@option[:description]} class="card-radio-description">
|
|
|
|
|
|
{@option.description}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
2026-03-04 07:12:25 +00:00
|
|
|
|
<.external_link
|
2026-02-21 19:29:34 +00:00
|
|
|
|
:if={@option[:url]}
|
|
|
|
|
|
href={@option.url}
|
2026-03-04 07:12:25 +00:00
|
|
|
|
icon={false}
|
2026-02-21 19:29:34 +00:00
|
|
|
|
class="card-radio-link"
|
|
|
|
|
|
onclick="event.stopPropagation();"
|
2026-03-04 07:12:25 +00:00
|
|
|
|
aria-label={@option.name}
|
2026-02-21 19:29:34 +00:00
|
|
|
|
>
|
|
|
|
|
|
{@option.name} ↗
|
2026-03-04 07:12:25 +00:00
|
|
|
|
</.external_link>
|
2026-02-21 19:29:34 +00:00
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-01-31 22:08:34 +00:00
|
|
|
|
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
|
|
|
|
|
js
|
|
|
|
|
|
|> JS.exec("showModal()", to: "##{id}")
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
def hide_modal(js \\ %JS{}, id) when is_binary(id) do
|
|
|
|
|
|
js
|
|
|
|
|
|
|> JS.exec("close()", to: "##{id}")
|
|
|
|
|
|
|> JS.pop_focus()
|
|
|
|
|
|
end
|
2026-03-01 09:42:34 +00:00
|
|
|
|
|
|
|
|
|
|
# ── Pagination ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
|
Renders pagination controls for admin lists.
|
|
|
|
|
|
|
|
|
|
|
|
Hidden when there's only one page. Shows "Showing X-Y of Z" on the left,
|
|
|
|
|
|
page number buttons with ellipsis on the right.
|
|
|
|
|
|
|
|
|
|
|
|
## Attributes
|
|
|
|
|
|
|
|
|
|
|
|
* `page` - Required. A `%Berrypod.Pagination{}` struct.
|
|
|
|
|
|
* `patch` - Required. Base URL path for pagination links (e.g. "/admin/products").
|
|
|
|
|
|
* `params` - Extra query params to preserve (e.g. %{"tab" => "broken"}). Default `%{}`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
attr :page, Berrypod.Pagination, required: true
|
|
|
|
|
|
attr :patch, :string, required: true
|
|
|
|
|
|
attr :params, :map, default: %{}
|
|
|
|
|
|
|
|
|
|
|
|
def admin_pagination(assigns) do
|
|
|
|
|
|
assigns =
|
|
|
|
|
|
assigns
|
|
|
|
|
|
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|
|
|
|
|
|
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
|
|
|
|
|
|
|
|
|
|
|
|
~H"""
|
|
|
|
|
|
<nav
|
|
|
|
|
|
:if={@page.total_pages > 1}
|
|
|
|
|
|
aria-label="Pagination"
|
|
|
|
|
|
class="admin-pagination"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="admin-pagination-showing">{@showing}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="admin-pagination-buttons">
|
|
|
|
|
|
<.link
|
|
|
|
|
|
patch={admin_page_url(@patch, @page.page - 1, @params)}
|
|
|
|
|
|
class={["admin-btn admin-btn-sm admin-btn-ghost", @page.page == 1 && "admin-btn-disabled"]}
|
|
|
|
|
|
aria-label="Previous page"
|
|
|
|
|
|
aria-disabled={@page.page == 1 && "true"}
|
|
|
|
|
|
tabindex={@page.page == 1 && "-1"}
|
|
|
|
|
|
>
|
|
|
|
|
|
<.icon name="hero-chevron-left" class="size-4" />
|
|
|
|
|
|
</.link>
|
|
|
|
|
|
|
|
|
|
|
|
<%= for item <- @numbers do %>
|
|
|
|
|
|
<%= case item do %>
|
|
|
|
|
|
<% :ellipsis -> %>
|
|
|
|
|
|
<span class="admin-pagination-ellipsis" aria-hidden="true">…</span>
|
|
|
|
|
|
<% n -> %>
|
|
|
|
|
|
<.link
|
|
|
|
|
|
patch={admin_page_url(@patch, n, @params)}
|
|
|
|
|
|
aria-label={"Page #{n}"}
|
|
|
|
|
|
aria-current={n == @page.page && "page"}
|
|
|
|
|
|
class={[
|
|
|
|
|
|
"admin-btn admin-btn-sm",
|
|
|
|
|
|
if(n == @page.page, do: "admin-btn-primary", else: "admin-btn-ghost")
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{n}
|
|
|
|
|
|
</.link>
|
|
|
|
|
|
<% end %>
|
|
|
|
|
|
<% end %>
|
|
|
|
|
|
|
|
|
|
|
|
<.link
|
|
|
|
|
|
patch={admin_page_url(@patch, @page.page + 1, @params)}
|
|
|
|
|
|
class={[
|
|
|
|
|
|
"admin-btn admin-btn-sm admin-btn-ghost",
|
|
|
|
|
|
@page.page == @page.total_pages && "admin-btn-disabled"
|
|
|
|
|
|
]}
|
|
|
|
|
|
aria-label="Next page"
|
|
|
|
|
|
aria-disabled={@page.page == @page.total_pages && "true"}
|
|
|
|
|
|
tabindex={@page.page == @page.total_pages && "-1"}
|
|
|
|
|
|
>
|
|
|
|
|
|
<.icon name="hero-chevron-right" class="size-4" />
|
|
|
|
|
|
</.link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
defp admin_page_url(base, page, params) do
|
|
|
|
|
|
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
|
|
|
|
|
|
if query == %{}, do: base, else: base <> "?" <> URI.encode_query(query)
|
|
|
|
|
|
end
|
2025-12-30 12:26:26 +00:00
|
|
|
|
end
|