Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
762
lib/tarakan_web/components/core_components.ex
Normal file
762
lib/tarakan_web/components/core_components.ex
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
defmodule TarakanWeb.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://phoenix-live-view.hexdocs.pm/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: TarakanWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash
|
||||
id="welcome-back"
|
||||
kind={:info}
|
||||
phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
|
||||
hidden
|
||||
>
|
||||
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 :auto_dismiss, :boolean, default: true, doc: "automatically clears ordinary toast messages"
|
||||
attr :dismiss_after, :integer, default: nil, doc: "auto-dismiss delay in milliseconds"
|
||||
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 =
|
||||
assigns
|
||||
|> assign_new(:id, fn -> "flash-#{assigns.kind}" end)
|
||||
|> assign(:dismiss_after, assigns.dismiss_after || default_dismiss_after(assigns.kind))
|
||||
|
||||
~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={@auto_dismiss && "AutoDismiss"}
|
||||
data-auto-dismiss-ms={@auto_dismiss && @dismiss_after}
|
||||
role="alert"
|
||||
class="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-[max(1rem,env(safe-area-inset-right))] left-[max(1rem,env(safe-area-inset-left))] z-50 w-auto max-w-[min(24rem,calc(100vw-2rem))] sm:left-auto"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"flex items-start gap-3 border-2 bg-panel px-4 py-3 text-sm text-ink shadow-2xl",
|
||||
@kind == :info && "border-strong",
|
||||
@kind == :error && "border-signal"
|
||||
]}>
|
||||
<.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>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp default_dismiss_after(:info), do: 5_000
|
||||
defp default_dismiss_after(:error), do: 8_000
|
||||
|
||||
@doc """
|
||||
Small notched badge with a continuous outline (or solid fill via `bg-ink` /
|
||||
`bg-signal`). Use instead of `border` + `clip-notch-sm`.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def notch_badge(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
id={@id}
|
||||
class={[
|
||||
"wire-badge font-display text-[10px] uppercase tracking-[0.12em]",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<span class="wire-badge-label">{render_slot(@inner_block)}</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
Controls sit outside the container border scale (2px = page region, 1px rule =
|
||||
division inside a region). A 2px stroke on a button competes with the region
|
||||
boundary it sits in, so secondary/danger buttons take a 1px stroke and primary
|
||||
takes none at all - the fill is its edge.
|
||||
|
||||
Reach for `variant`, never a hand-rolled class string: the point of the
|
||||
component is that Confirm and Dispute cannot silently drift apart.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button variant="danger" size="sm">Dispute</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
"""
|
||||
attr :rest, :global,
|
||||
include: ~w(href navigate patch method download name value disabled form type)
|
||||
|
||||
attr :class, :any, default: nil, doc: "extras only (margin, layout) - never competing styling"
|
||||
attr :variant, :string, default: nil, values: [nil, "primary", "danger", "quiet"]
|
||||
attr :size, :string, default: "md", values: ~w(sm md)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
# Primary is filled + clip-notch (solid edge). Outline buttons omit clip-notch:
|
||||
# border + clip-path removes the top stroke.
|
||||
variants = %{
|
||||
"primary" => "clip-notch bg-btn text-btn-fg hover:opacity-90",
|
||||
"danger" => "border border-signal text-signal hover:bg-signal hover:text-btn-fg",
|
||||
"quiet" => "text-ink-muted hover:text-ink",
|
||||
nil => "border border-strong text-ink hover:bg-panel"
|
||||
}
|
||||
|
||||
sizes = %{
|
||||
"md" => "px-4 py-2 text-sm",
|
||||
"sm" => "px-3 py-1.5 text-[11px]"
|
||||
}
|
||||
|
||||
assigns =
|
||||
assign(assigns, :button_class, [
|
||||
"inline-flex items-center justify-center gap-1.5 font-display uppercase tracking-[0.12em]",
|
||||
"transition focus:outline-none focus:ring-2 focus:ring-phosphor",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
Map.fetch!(sizes, assigns.size),
|
||||
Map.fetch!(variants, assigns.variant),
|
||||
assigns.class
|
||||
])
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
~H"""
|
||||
<.link class={@button_class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={@button_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 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://phoenix-html.hexdocs.pm/Phoenix.HTML.Form.html#options_for_select/2).
|
||||
"""
|
||||
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 hidden)
|
||||
|
||||
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, :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 :hide_errors, :boolean,
|
||||
default: false,
|
||||
doc: "suppress inline error rendering; pair with <.field_errors> placed outside the input"
|
||||
|
||||
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: "hidden"} = assigns) do
|
||||
~H"""
|
||||
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<label for={@id}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={@name}
|
||||
value="false"
|
||||
disabled={@rest[:disabled]}
|
||||
form={@rest[:form]}
|
||||
/>
|
||||
<span class="flex items-center gap-2 text-sm text-ink-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "size-4 border-strong bg-panel accent-phosphor focus:ring-phosphor"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label for={@id}>
|
||||
<span
|
||||
:if={@label}
|
||||
class="mb-1.5 block font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>{@label}</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class ||
|
||||
"w-full border border-strong bg-panel px-3 py-2 text-ink placeholder:text-ink-faint focus:border-phosphor focus:outline-none focus:ring-0",
|
||||
@errors != [] && (@error_class || "border-signal")
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label for={@id}>
|
||||
<span
|
||||
:if={@label}
|
||||
class="mb-1.5 block font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>{@label}</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class ||
|
||||
"w-full border border-strong bg-panel px-3 py-2 text-ink placeholder:text-ink-faint focus:border-phosphor focus:outline-none focus:ring-0",
|
||||
@errors != [] && (@error_class || "border-signal")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label for={@id}>
|
||||
<span
|
||||
:if={@label}
|
||||
class="mb-1.5 block font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>{@label}</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class ||
|
||||
"w-full border border-strong bg-panel px-3 py-2 text-ink placeholder:text-ink-faint focus:border-phosphor focus:outline-none focus:ring-0",
|
||||
@errors != [] && (@error_class || "border-signal")
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a field's errors on their own, for inputs embedded in composed
|
||||
controls (set `hide_errors` on the input and place this outside the frame).
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:url]} hide_errors ... />
|
||||
<.field_errors field={@form[:url]} />
|
||||
"""
|
||||
attr :field, Phoenix.HTML.FormField, required: true
|
||||
|
||||
def field_errors(assigns) do
|
||||
errors =
|
||||
if Phoenix.Component.used_input?(assigns.field), do: assigns.field.errors, else: []
|
||||
|
||||
assigns = assign(assigns, :messages, Enum.map(errors, &translate_error/1))
|
||||
|
||||
~H"""
|
||||
<.error :for={msg <- @messages} id={"#{@field.id}-error"}>{msg}</.error>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
attr :id, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p id={@id} class="mt-1.5 flex items-center gap-2 text-sm text-signal">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the standard breadcrumb trail used at the top of interior pages.
|
||||
|
||||
Crumbs with `navigate` render as links; crumbs without render as static
|
||||
text. The final crumb is the current page and reads brighter than the
|
||||
trail.
|
||||
|
||||
## Examples
|
||||
|
||||
<.breadcrumbs id="finding-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>finding</:crumb>
|
||||
</.breadcrumbs>
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
|
||||
slot :crumb, required: true do
|
||||
attr :navigate, :string
|
||||
end
|
||||
|
||||
def breadcrumbs(assigns) do
|
||||
assigns = assign(assigns, :last_index, length(assigns.crumb) - 1)
|
||||
|
||||
~H"""
|
||||
<nav
|
||||
id={@id}
|
||||
class="mb-3 flex min-w-0 flex-wrap items-center gap-2 font-mono text-xs text-ink-faint"
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<%= for {crumb, index} <- Enum.with_index(@crumb) do %>
|
||||
<span :if={index > 0} aria-hidden="true">/</span>
|
||||
<.link
|
||||
:if={crumb[:navigate]}
|
||||
navigate={crumb.navigate}
|
||||
class={[
|
||||
"min-w-0 truncate transition hover:text-ink",
|
||||
index == @last_index && "text-ink-muted"
|
||||
]}
|
||||
>
|
||||
{render_slot(crumb)}
|
||||
</.link>
|
||||
<span
|
||||
:if={!crumb[:navigate]}
|
||||
class={["min-w-0 truncate", index == @last_index && "text-ink-muted"]}
|
||||
>
|
||||
{render_slot(crumb)}
|
||||
</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a handle as a link to its public profile. The `@` is part of the
|
||||
link text so the whole mention is clickable.
|
||||
|
||||
## Examples
|
||||
|
||||
<.handle_link handle={@scan.submitted_by.handle} />
|
||||
<.handle_link handle={comment.account.handle} class="font-semibold" />
|
||||
"""
|
||||
attr :handle, :string, required: true
|
||||
attr :class, :any, default: nil
|
||||
|
||||
def handle_link(assigns) do
|
||||
~H"""
|
||||
<.link navigate={"/" <> @handle} class={["transition hover:text-signal", @class]}>@{@handle}</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an up/down vote control for a votable subject (a canonical finding or a
|
||||
comment). The score is the plain net of votes; the caller's own vote is
|
||||
highlighted. Emits `phx-click="vote"` with the subject type, id, and value.
|
||||
|
||||
## Examples
|
||||
|
||||
<.vote_control subject_type="canonical_finding" subject_id={@canonical.id} summary={@finding_votes} can_vote={@can_vote} />
|
||||
"""
|
||||
attr :subject_type, :string, required: true
|
||||
attr :subject_id, :integer, required: true
|
||||
attr :summary, :map, default: %{score: 0, my_vote: 0}
|
||||
attr :can_vote, :boolean, default: false
|
||||
attr :class, :any, default: nil
|
||||
|
||||
def vote_control(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"vote-#{@subject_type}-#{@subject_id}"}
|
||||
class={["inline-flex items-center gap-0.5 font-mono text-[11px] sm:gap-1", @class]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="vote"
|
||||
phx-value-type={@subject_type}
|
||||
phx-value-id={@subject_id}
|
||||
phx-value-vote="1"
|
||||
disabled={!@can_vote}
|
||||
aria-label="Upvote"
|
||||
class={[
|
||||
"flex size-9 items-center justify-center transition disabled:cursor-not-allowed sm:size-6",
|
||||
@summary.my_vote == 1 && "text-quote",
|
||||
@summary.my_vote != 1 && "text-ink-faint enabled:hover:text-quote"
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-chevron-up-mini" class="size-4" />
|
||||
</button>
|
||||
<span class={[
|
||||
"min-w-5 text-center tabular-nums",
|
||||
@summary.score > 0 && "text-quote",
|
||||
@summary.score < 0 && "text-signal",
|
||||
@summary.score == 0 && "text-ink-muted"
|
||||
]}>
|
||||
{@summary.score}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="vote"
|
||||
phx-value-type={@subject_type}
|
||||
phx-value-id={@subject_id}
|
||||
phx-value-vote="-1"
|
||||
disabled={!@can_vote}
|
||||
aria-label="Downvote"
|
||||
class={[
|
||||
"flex size-9 items-center justify-center transition disabled:cursor-not-allowed sm:size-6",
|
||||
@summary.my_vote == -1 && "text-signal",
|
||||
@summary.my_vote != -1 && "text-ink-faint enabled:hover:text-signal"
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-chevron-down-mini" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders one discussion comment and its replies, recursively.
|
||||
|
||||
Nesting indents up to a fixed depth, then stops so deep chains don't run
|
||||
off the page. Removed comments render as a placeholder that keeps the
|
||||
thread intact.
|
||||
"""
|
||||
attr :comment, :map, required: true
|
||||
attr :reply_to, :string, default: nil
|
||||
attr :can_reply, :boolean, default: false
|
||||
attr :can_moderate, :boolean, default: false
|
||||
attr :can_vote, :boolean, default: false
|
||||
attr :votes, :map, default: %{}
|
||||
|
||||
def comment_thread(assigns) do
|
||||
~H"""
|
||||
<div id={"comment-#{@comment.id}"} class="border-l-2 border-rule pl-3">
|
||||
<div class="flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
|
||||
<.vote_control
|
||||
:if={is_nil(@comment.removed_at)}
|
||||
subject_type="comment"
|
||||
subject_id={@comment.id}
|
||||
summary={Map.get(@votes, @comment.id, %{score: 0, my_vote: 0})}
|
||||
can_vote={@can_vote}
|
||||
class="mr-1"
|
||||
/>
|
||||
<.handle_link handle={@comment.account.handle} class="font-semibold text-ink-muted" />
|
||||
<span class="tabular-nums">{comment_time(@comment.inserted_at)}</span>
|
||||
<span
|
||||
:if={@comment.removed_at}
|
||||
class="border border-signal px-1 text-[10px] uppercase tracking-[0.12em] text-signal"
|
||||
>
|
||||
removed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
:if={is_nil(@comment.removed_at)}
|
||||
class="mt-1 whitespace-pre-line text-sm leading-6 text-ink"
|
||||
phx-no-format
|
||||
>{@comment.body}</p>
|
||||
<p :if={@comment.removed_at} class="mt-1 text-sm italic leading-6 text-ink-faint">
|
||||
Removed by moderation: {@comment.removed_reason}<span
|
||||
:if={@comment.body}
|
||||
class="ml-2 not-italic"
|
||||
>- original: “{@comment.body}”</span>
|
||||
</p>
|
||||
|
||||
<div class="mt-1 flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.12em]">
|
||||
<button
|
||||
:if={@can_reply and is_nil(@comment.removed_at)}
|
||||
type="button"
|
||||
phx-click="reply_to"
|
||||
phx-value-parent={@comment.id}
|
||||
class="text-ink-faint transition hover:text-ink"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
<button
|
||||
:if={@can_moderate and is_nil(@comment.removed_at)}
|
||||
type="button"
|
||||
phx-click="remove_comment"
|
||||
phx-value-id={@comment.id}
|
||||
phx-value-reason="moderator_removed"
|
||||
data-confirm="Remove this comment from the public discussion?"
|
||||
class="text-ink-faint transition hover:text-signal"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
:if={@can_reply and @reply_to == to_string(@comment.id)}
|
||||
id={"reply-form-#{@comment.id}"}
|
||||
phx-submit="post_comment"
|
||||
class="mt-2 flex flex-col gap-2"
|
||||
>
|
||||
<input type="hidden" name="parent_id" value={@comment.id} />
|
||||
<textarea
|
||||
name="body"
|
||||
rows="2"
|
||||
phx-mounted={JS.focus()}
|
||||
placeholder="Add to this thread…"
|
||||
class="w-full border border-strong bg-transparent px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-phosphor"
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button class="clip-notch bg-btn px-3 py-1 font-display text-[11px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
|
||||
Reply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_reply"
|
||||
class="px-3 py-1 font-display text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div :if={@comment.replies != []} class="mt-3 space-y-3">
|
||||
<.comment_thread
|
||||
:for={reply <- @comment.replies}
|
||||
comment={reply}
|
||||
reply_to={@reply_to}
|
||||
can_reply={@can_reply}
|
||||
can_moderate={@can_moderate}
|
||||
can_vote={@can_vote}
|
||||
votes={@votes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp comment_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium uppercase leading-9 tracking-[0.02em] text-ink">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-1 text-sm text-ink-muted">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
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 """
|
||||
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(TarakanWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(TarakanWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue