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
98
lib/tarakan_web/components/bounty_components.ex
Normal file
98
lib/tarakan_web/components/bounty_components.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule TarakanWeb.BountyComponents do
|
||||
@moduledoc """
|
||||
Shared display helpers and components for the bounty marketplace.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :html
|
||||
|
||||
alias Tarakan.Market.Bounty
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@doc "Human-facing reward amount: `$1,234` for fiat, `1,234 credits` for credit bounties."
|
||||
def amount_label(%Bounty{funding: "fiat", amount_cents: cents}) when is_integer(cents) do
|
||||
"$#{format_number(div(cents, 100))}"
|
||||
end
|
||||
|
||||
def amount_label(%Bounty{funding: "credits", credit_amount: amount}) when is_integer(amount) do
|
||||
"#{format_number(amount)} credits"
|
||||
end
|
||||
|
||||
def amount_label(_bounty), do: " - "
|
||||
|
||||
def status_label("pending_funding"), do: "awaiting funding"
|
||||
def status_label("open"), do: "open"
|
||||
def status_label("claimed"), do: "claimed"
|
||||
def status_label("fulfilled"), do: "fulfilled"
|
||||
def status_label("payout_pending"), do: "payout pending"
|
||||
def status_label("paid"), do: "paid"
|
||||
def status_label("cancelled"), do: "cancelled"
|
||||
def status_label("expired"), do: "expired"
|
||||
def status_label(other), do: other
|
||||
|
||||
@doc "Path to the bounty's public target page."
|
||||
def target_path(%Bounty{target_type: "repository", repository: %_{} = repository}),
|
||||
do: RepositoryPaths.repository_security_path(repository)
|
||||
|
||||
def target_path(%Bounty{target_type: "infestation", pattern_key: pattern_key}),
|
||||
do: ~p"/infestations/#{pattern_key}"
|
||||
|
||||
def target_path(%Bounty{target_type: "finding", canonical_finding: %_{} = finding}),
|
||||
do: ~p"/findings/#{finding.public_id}"
|
||||
|
||||
def target_path(_bounty), do: nil
|
||||
|
||||
@doc "Short label identifying the bounty's target."
|
||||
def target_label(%Bounty{target_type: "repository", repository: %_{} = repository}),
|
||||
do: "#{repository.owner}/#{repository.name}"
|
||||
|
||||
def target_label(%Bounty{target_type: "infestation", pattern_key: pattern_key}),
|
||||
do: pattern_key
|
||||
|
||||
def target_label(%Bounty{target_type: "finding", canonical_finding: %_{} = finding}),
|
||||
do: finding.title
|
||||
|
||||
def target_label(_bounty), do: "unknown target"
|
||||
|
||||
@doc """
|
||||
Compact "Contract" strip rendered on target pages when open bounties exist.
|
||||
Links to the richest open contract for the target.
|
||||
"""
|
||||
attr :bounties, :list, required: true
|
||||
|
||||
def wanted_banner(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
:if={@bounties != []}
|
||||
id="wanted-banner"
|
||||
class="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 border-2 border-strong bg-panel px-4 py-3"
|
||||
>
|
||||
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
|
||||
<p class="text-sm text-ink">
|
||||
<span class="font-semibold">{amount_label(hd(@bounties))}</span>
|
||||
<span class="text-ink-muted">
|
||||
offered for work on this target
|
||||
<span :if={length(@bounties) > 1}>
|
||||
(+ {length(@bounties) - 1} more {if length(@bounties) == 2,
|
||||
do: "contract",
|
||||
else: "contracts"})
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/bounties/#{hd(@bounties).public_id}"}
|
||||
class="ml-auto font-mono text-xs text-signal transition hover:underline"
|
||||
>
|
||||
View contract →
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_number(number) do
|
||||
number
|
||||
|> Integer.to_string()
|
||||
|> String.reverse()
|
||||
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|
||||
|> String.reverse()
|
||||
end
|
||||
end
|
||||
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
|
||||
219
lib/tarakan_web/components/infestation_components.ex
Normal file
219
lib/tarakan_web/components/infestation_components.ex
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
defmodule TarakanWeb.InfestationComponents do
|
||||
@moduledoc """
|
||||
Functional infestation surfaces: ranked pattern table and per-pattern repo matrix.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
router: TarakanWeb.Router,
|
||||
statics: TarakanWeb.static_paths()
|
||||
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
attr :infestations, :list, required: true
|
||||
attr :id, :string, default: "infestation-constellation"
|
||||
attr :compact, :boolean, default: false
|
||||
|
||||
@doc "Ranked multi-repo patterns (homepage / index)."
|
||||
def constellation(assigns) do
|
||||
max_repos =
|
||||
assigns.infestations
|
||||
|> Enum.map(& &1.repo_count)
|
||||
|> Enum.max(fn -> 1 end)
|
||||
|
||||
assigns = assign(assigns, :max_repos, max(max_repos, 1))
|
||||
|
||||
~H"""
|
||||
<div id={@id} class="border-2 border-strong bg-ground">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[36rem] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<th class="w-10 px-3 py-2 font-normal sm:px-4">#</th>
|
||||
<th class="px-2 py-2 font-normal">Pattern</th>
|
||||
<th class="w-[7rem] px-2 py-2 font-normal sm:w-[10rem]">Spread</th>
|
||||
<th class="w-16 px-2 py-2 text-right font-normal tabular-nums">Repos</th>
|
||||
<th class="hidden w-14 px-2 py-2 text-right font-normal sm:table-cell">Open</th>
|
||||
<th class="hidden w-14 px-2 py-2 text-right font-normal md:table-cell">Ver</th>
|
||||
<th class="hidden w-14 px-2 py-2 text-right font-normal md:table-cell">Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-rule">
|
||||
<tr
|
||||
:for={{infestation, i} <- Enum.with_index(@infestations, 1)}
|
||||
class="group transition-colors hover:bg-panel"
|
||||
>
|
||||
<td class="px-3 py-2.5 font-mono text-[11px] tabular-nums text-ink-faint sm:px-4">
|
||||
{i}
|
||||
</td>
|
||||
<td class="min-w-0 px-2 py-2.5">
|
||||
<.link
|
||||
id={"#{@id}-hub-#{infestation.pattern_key}"}
|
||||
navigate={~p"/infestations/#{infestation.pattern_key}"}
|
||||
class="block min-w-0"
|
||||
>
|
||||
<span class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
:if={infestation.severity}
|
||||
class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-signal"
|
||||
>
|
||||
{infestation.severity}
|
||||
</span>
|
||||
<span class="truncate text-sm font-semibold text-ink group-hover:text-signal">
|
||||
{infestation.title}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
:if={!@compact}
|
||||
class="mt-0.5 block truncate font-mono text-[10px] text-ink-faint"
|
||||
>
|
||||
{String.slice(infestation.pattern_key, 0, 12)}…
|
||||
</span>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 min-w-0 flex-1 bg-rule">
|
||||
<div
|
||||
class="h-full bg-signal"
|
||||
style={"width: #{bar_pct(infestation.repo_count, @max_repos)}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-display text-lg tabular-nums text-ink">
|
||||
{infestation.repo_count}
|
||||
</td>
|
||||
<td class="hidden px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor sm:table-cell">
|
||||
{infestation.open_count}
|
||||
</td>
|
||||
<td class="hidden px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink md:table-cell">
|
||||
{infestation.verified_count}
|
||||
</td>
|
||||
<td class="hidden px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted md:table-cell">
|
||||
{infestation.fixed_count}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :repos, :list, required: true, doc: "pattern_repos rows: one per repository"
|
||||
attr :infestation, :map, required: true
|
||||
attr :pattern_key, :string, required: true
|
||||
attr :id, :string, default: "infestation-graph"
|
||||
|
||||
@doc """
|
||||
Per-repo infestation matrix for one pattern.
|
||||
|
||||
One row per repository, rolled up from that repo's instances. The instance
|
||||
grain (one row per finding) is the separate ledger on the same page.
|
||||
"""
|
||||
def infestation_graph(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="border-2 border-strong bg-ground">
|
||||
<div
|
||||
:if={@repos == []}
|
||||
class="px-4 py-10 text-center font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No listed public repositories.
|
||||
</div>
|
||||
|
||||
<div :if={@repos != []} class="overflow-x-auto">
|
||||
<table class="w-full min-w-[32rem] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<th class="w-8 px-3 py-2 font-normal sm:px-4"></th>
|
||||
<th class="px-2 py-2 font-normal">Repository</th>
|
||||
<th class="w-24 px-2 py-2 font-normal">Status</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal tabular-nums">Findings</th>
|
||||
<th class="hidden w-16 px-2 py-2 text-right font-normal tabular-nums sm:table-cell">
|
||||
Open
|
||||
</th>
|
||||
<th class="w-20 px-3 py-2 text-right font-normal sm:px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-rule">
|
||||
<tr
|
||||
:for={repo <- @repos}
|
||||
id={"#{@id}-node-#{repo.id}"}
|
||||
class="group transition-colors hover:bg-panel"
|
||||
>
|
||||
<td class="px-3 py-2.5 sm:px-4">
|
||||
<span class={[
|
||||
"inline-block size-2 rounded-full",
|
||||
repo.status == "open" && "bg-phosphor",
|
||||
repo.status == "verified" && "bg-ink",
|
||||
repo.status == "fixed" && "bg-ink-muted",
|
||||
repo.status not in ["open", "verified", "fixed"] && "bg-ink-faint"
|
||||
]}></span>
|
||||
</td>
|
||||
<td class="min-w-0 px-2 py-2.5">
|
||||
<.link
|
||||
:if={repo[:host]}
|
||||
navigate={RepositoryPaths.repository_path(repo)}
|
||||
class="block truncate font-mono text-xs font-semibold text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{repo.owner}/{repo.name}
|
||||
</.link>
|
||||
<span
|
||||
:if={is_nil(repo[:host])}
|
||||
class="block truncate font-mono text-xs font-semibold text-ink"
|
||||
>
|
||||
{repo.owner}/{repo.name}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<span class={[
|
||||
"font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
repo.status == "open" && "text-phosphor",
|
||||
repo.status == "verified" && "text-ink",
|
||||
repo.status == "fixed" && "text-ink-muted",
|
||||
repo.status not in ["open", "verified", "fixed"] && "text-ink-faint"
|
||||
]}>
|
||||
{repo.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{repo[:instance_count] || 1}
|
||||
</td>
|
||||
<td class={[
|
||||
"hidden px-2 py-2.5 text-right font-mono text-xs tabular-nums sm:table-cell",
|
||||
(repo[:open_count] || 0) > 0 && "text-phosphor",
|
||||
(repo[:open_count] || 0) == 0 && "text-ink-faint"
|
||||
]}>
|
||||
{repo[:open_count] || 0}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-right sm:px-4">
|
||||
<.link
|
||||
:if={repo.occurrence_public_id}
|
||||
navigate={~p"/findings/#{repo.occurrence_public_id}"}
|
||||
class="font-mono text-[11px] text-signal hover:underline"
|
||||
>
|
||||
Finding →
|
||||
</.link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp bar_pct(count, max) when max > 0 do
|
||||
count
|
||||
|> Kernel./(max)
|
||||
|> Kernel.*(100)
|
||||
|> Float.round(1)
|
||||
|> max(4)
|
||||
|> min(100)
|
||||
end
|
||||
|
||||
defp bar_pct(_, _), do: 0
|
||||
end
|
||||
765
lib/tarakan_web/components/layouts.ex
Normal file
765
lib/tarakan_web/components/layouts.ex
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
defmodule TarakanWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds layouts and related functionality
|
||||
used by your application.
|
||||
"""
|
||||
use TarakanWeb, :html
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
# and other static content.
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
The Tarakan mark: a blade asterisk, six blades off one centre at (33,33),
|
||||
every outer tip a point. The spine is the heavy axis (full width y=18..48);
|
||||
the four diagonals carry their widest section a third of the way out and
|
||||
converge at the centre, which the spine keeps solid.
|
||||
"""
|
||||
attr :class, :any, default: "h-6 w-6"
|
||||
|
||||
def logo_mark(assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
class={@class}
|
||||
viewBox="0 0 66 66"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
data-tarakan-logo
|
||||
>
|
||||
<polygon points="33,0 41,18 41,48 33,66 25,48 25,18" />
|
||||
<polygon points="0,0 18,26 33,33 26,18" />
|
||||
<polygon points="66,0 48,26 33,33 40,18" />
|
||||
<polygon points="0,66 18,40 33,33 26,48" />
|
||||
<polygon points="66,66 48,40 33,33 40,48" />
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shared page shell: gutters, max width, and vertical padding under the nav.
|
||||
|
||||
All app pages should use this so spacing stays consistent. Pass `class` only
|
||||
for extras (e.g. `space-y-5`), not for competing padding.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :width, :atom, default: :wide, values: [:wide, :focused, :compact, :form]
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def page(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={
|
||||
[
|
||||
"mx-auto w-full min-w-0 px-4 sm:px-8",
|
||||
# Consistent clearance from sticky nav + bottom breathing room.
|
||||
"pt-6 pb-10 sm:pt-10 sm:pb-12",
|
||||
@width == :wide && "max-w-[90rem]",
|
||||
@width == :focused && "max-w-3xl",
|
||||
@width == :compact && "max-w-xl",
|
||||
@width == :form && "max-w-md",
|
||||
@class
|
||||
]
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders your app layout.
|
||||
|
||||
This function is typically invoked from every template,
|
||||
and it often contains your application menu, sidebar,
|
||||
or similar.
|
||||
|
||||
## Examples
|
||||
|
||||
<Layouts.app flash={@flash}>
|
||||
<h1>Content</h1>
|
||||
</Layouts.app>
|
||||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
|
||||
|
||||
attr :current_path, :string,
|
||||
default: nil,
|
||||
doc: "request path, supplied by TarakanWeb.CurrentPath; drives the nav highlight"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<div class="min-h-dvh min-w-0 overflow-x-clip bg-ground text-ink antialiased">
|
||||
<header
|
||||
id="site-header"
|
||||
class="sticky top-0 z-40 border-b-2 border-strong bg-ground pt-[env(safe-area-inset-top)]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 -bottom-0.5 h-0.5 bg-gradient-to-r from-signal via-signal/40 to-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex h-14 w-full min-w-0 items-center gap-2 px-3 md:items-stretch md:gap-0 md:px-0">
|
||||
<%!-- Brand --%>
|
||||
<.link
|
||||
navigate={~p"/"}
|
||||
aria-label="Tarakan home"
|
||||
class="flex h-9 shrink-0 items-center gap-2 md:h-auto md:border-r-2 md:border-strong md:px-8"
|
||||
>
|
||||
<span class="font-display text-sm font-bold uppercase tracking-[0.12em] text-ink md:text-base md:tracking-[0.12em]">
|
||||
Tarakan
|
||||
</span>
|
||||
</.link>
|
||||
|
||||
<%!-- Desktop primary nav --%>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
class={[
|
||||
"hidden min-w-0 flex-1 items-stretch overflow-x-auto overscroll-x-contain md:flex",
|
||||
"[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
]}
|
||||
>
|
||||
<.nav_link path={~p"/explore"} current={@current_path} label="Explore" />
|
||||
<.nav_link path={~p"/infestations"} current={@current_path} label="Infestations" />
|
||||
<.nav_link path={~p"/jobs"} current={@current_path} label="Jobs" />
|
||||
<.nav_link path={~p"/bounties"} current={@current_path} label="Contracts" />
|
||||
<.nav_link path={~p"/agents"} current={@current_path} label="Agents" />
|
||||
<.nav_link path={~p"/leaderboard"} current={@current_path} label="Leaderboard" />
|
||||
<.nav_link path={~p"/models"} current={@current_path} label="Models" />
|
||||
<.nav_link path={~p"/pricing"} current={@current_path} label="Pricing" />
|
||||
</nav>
|
||||
|
||||
<%!-- Desktop utilities --%>
|
||||
<div class="ml-auto hidden h-14 items-stretch md:flex">
|
||||
<div
|
||||
id="theme-toggle"
|
||||
class="flex items-stretch border-l-2 border-rule"
|
||||
role="group"
|
||||
aria-label="Color theme"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="light"
|
||||
aria-pressed="false"
|
||||
aria-label="Light theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "light"})}
|
||||
class="flex w-10 cursor-pointer items-center justify-center text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="system"
|
||||
aria-pressed="false"
|
||||
aria-label="Follow system theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "system"})}
|
||||
class="flex w-10 cursor-pointer items-center justify-center text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="dark"
|
||||
aria-pressed="false"
|
||||
aria-label="Dark theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "dark"})}
|
||||
class="flex w-10 cursor-pointer items-center justify-center text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
<details
|
||||
id="current-account"
|
||||
class="relative flex items-stretch"
|
||||
phx-click-away={JS.remove_attribute("open", to: "#current-account")}
|
||||
>
|
||||
<summary class="flex cursor-pointer list-none items-center gap-1.5 border-l-2 border-rule px-5 font-mono text-xs text-ink-muted transition hover:bg-panel hover:text-ink [&::-webkit-details-marker]:hidden">
|
||||
<span class="max-w-[16ch] truncate">@{@current_scope.account.handle}</span>
|
||||
<.icon name="hero-chevron-down-micro" class="size-3 shrink-0 text-ink-faint" />
|
||||
</summary>
|
||||
<nav
|
||||
aria-label="Account"
|
||||
class="absolute right-0 top-full z-50 w-56 divide-y divide-rule border-2 border-strong bg-ground shadow-2xl"
|
||||
>
|
||||
<.link
|
||||
id="header-profile"
|
||||
navigate={"/" <> @current_scope.account.handle}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Your contributions
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/accounts/settings"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/accounts/billing"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Billing
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Alerts
|
||||
</.link>
|
||||
<.link
|
||||
id="header-report-content"
|
||||
navigate={~p"/moderation/report"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Report content
|
||||
</.link>
|
||||
<.link
|
||||
:if={
|
||||
@current_scope.account_state == "active" &&
|
||||
@current_scope.platform_role in ["moderator", "admin"]
|
||||
}
|
||||
id="header-moderation-queue"
|
||||
navigate={~p"/moderation/queue"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Moderation queue
|
||||
</.link>
|
||||
<.link
|
||||
:if={@current_scope.platform_role == "admin"}
|
||||
id="header-admin-dashboard"
|
||||
navigate={~p"/admin"}
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-signal transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Administration
|
||||
</.link>
|
||||
<.link
|
||||
href={~p"/accounts/log-out"}
|
||||
method="delete"
|
||||
class="block px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-signal"
|
||||
>
|
||||
Sign out
|
||||
</.link>
|
||||
</nav>
|
||||
</details>
|
||||
<.link
|
||||
id="header-add-repository"
|
||||
navigate={~p"/repositories/new"}
|
||||
class="inline-flex items-center gap-1.5 border-l-2 border-strong bg-btn px-5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
<.icon name="hero-plus-micro" class="size-3.5" /> Add repository
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
id="header-login"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="inline-flex items-center border-l-2 border-rule px-5 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Sign in
|
||||
</.link>
|
||||
<.link
|
||||
id="header-register"
|
||||
navigate={~p"/accounts/register"}
|
||||
class="inline-flex items-center border-l-2 border-strong bg-btn px-5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Join Tarakan
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Mobile: one primary action + menu --%>
|
||||
<div class="ml-auto flex items-center gap-1.5 md:hidden">
|
||||
<.link
|
||||
:if={@current_scope && @current_scope.account}
|
||||
id="header-add-repository-mobile"
|
||||
navigate={~p"/repositories/new"}
|
||||
class="inline-flex h-9 items-center gap-1 bg-btn px-3 font-display text-[11px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
<.icon name="hero-plus-micro" class="size-3.5" /> Add
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
|
||||
id="header-login-mobile"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="inline-flex h-9 items-center border border-strong px-3 font-display text-[11px] uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Sign in
|
||||
</.link>
|
||||
|
||||
<details
|
||||
id="site-nav-mobile"
|
||||
class="relative"
|
||||
phx-click-away={JS.remove_attribute("open", to: "#site-nav-mobile")}
|
||||
>
|
||||
<summary
|
||||
class="flex h-9 w-9 cursor-pointer list-none items-center justify-center border border-strong text-ink transition hover:bg-panel [&::-webkit-details-marker]:hidden"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<.icon name="hero-bars-3-mini" class="size-5" />
|
||||
</summary>
|
||||
|
||||
<div class="absolute right-0 top-[calc(100%+0.4rem)] z-50 w-[min(18.5rem,calc(100vw-1.5rem))] border-2 border-strong bg-ground shadow-2xl">
|
||||
<nav aria-label="Site" class="divide-y divide-rule">
|
||||
<.mobile_nav_link path={~p"/explore"} current={@current_path} label="Explore" />
|
||||
<.mobile_nav_link
|
||||
path={~p"/infestations"}
|
||||
current={@current_path}
|
||||
label="Infestations"
|
||||
/>
|
||||
<.mobile_nav_link path={~p"/jobs"} current={@current_path} label="Jobs" />
|
||||
<.mobile_nav_link path={~p"/bounties"} current={@current_path} label="Contracts" />
|
||||
<.mobile_nav_link path={~p"/agents"} current={@current_path} label="Agents" />
|
||||
<.mobile_nav_link
|
||||
path={~p"/leaderboard"}
|
||||
current={@current_path}
|
||||
label="Leaderboard"
|
||||
/>
|
||||
<.mobile_nav_link path={~p"/models"} current={@current_path} label="Models" />
|
||||
<.mobile_nav_link path={~p"/pricing"} current={@current_path} label="Pricing" />
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id="theme-toggle-mobile"
|
||||
class="flex border-t-2 border-strong"
|
||||
role="group"
|
||||
aria-label="Color theme"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="light"
|
||||
aria-pressed="false"
|
||||
aria-label="Light theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "light"})}
|
||||
class="flex h-11 flex-1 cursor-pointer items-center justify-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-3.5" /> Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="system"
|
||||
aria-pressed="false"
|
||||
aria-label="Follow system theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "system"})}
|
||||
class="flex h-11 flex-1 cursor-pointer items-center justify-center gap-1.5 border-x border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-3.5" /> Auto
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-option="dark"
|
||||
aria-pressed="false"
|
||||
aria-label="Dark theme"
|
||||
phx-click={JS.dispatch("tarakan:set-theme", detail: %{theme: "dark"})}
|
||||
class="flex h-11 flex-1 cursor-pointer items-center justify-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-ink aria-pressed:bg-panel aria-pressed:text-ink"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-3.5" /> Dark
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t-2 border-strong">
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
<p class="px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
@{@current_scope.account.handle}
|
||||
</p>
|
||||
<.link
|
||||
id="header-profile-mobile"
|
||||
navigate={"/" <> @current_scope.account.handle}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Your contributions
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/accounts/settings"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/accounts/billing"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Billing
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Alerts
|
||||
</.link>
|
||||
<.link
|
||||
id="header-report-content-mobile"
|
||||
navigate={~p"/moderation/report"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Report content
|
||||
</.link>
|
||||
<.link
|
||||
:if={
|
||||
@current_scope.account_state == "active" &&
|
||||
@current_scope.platform_role in ["moderator", "admin"]
|
||||
}
|
||||
id="header-moderation-queue-mobile"
|
||||
navigate={~p"/moderation/queue"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Moderation queue
|
||||
</.link>
|
||||
<.link
|
||||
:if={@current_scope.platform_role == "admin"}
|
||||
id="header-admin-dashboard-mobile"
|
||||
navigate={~p"/admin"}
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-signal transition hover:bg-panel hover:text-ink"
|
||||
>
|
||||
Administration
|
||||
</.link>
|
||||
<.link
|
||||
href={~p"/accounts/log-out"}
|
||||
method="delete"
|
||||
class="block px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-signal"
|
||||
>
|
||||
Sign out
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
id="header-register-mobile"
|
||||
navigate={~p"/accounts/register"}
|
||||
class="block bg-btn px-4 py-3.5 text-center font-mono text-[11px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Join Tarakan
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="min-w-0">
|
||||
{render_slot(@inner_block)}
|
||||
</main>
|
||||
|
||||
<footer class="relative overflow-hidden border-t-2 border-strong pb-[env(safe-area-inset-bottom)]">
|
||||
<%!-- Oversized mark bleeding off the corner. Kept in --rule so it reads as
|
||||
surface texture, never as signal: red stays reserved for live state. --%>
|
||||
<.logo_mark class="pointer-events-none absolute -bottom-28 -right-16 hidden size-[26rem] text-rule lg:block" />
|
||||
|
||||
<div class="relative mx-auto w-full max-w-[90rem] px-4 py-12 sm:px-8">
|
||||
<div class="flex flex-col gap-10 lg:flex-row lg:justify-between lg:gap-16">
|
||||
<div class="shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<.logo_mark class="size-12 shrink-0 text-ink sm:size-14" />
|
||||
<p class="font-display text-3xl font-bold uppercase leading-none tracking-[0.12em] text-ink sm:text-4xl">
|
||||
Tarakan
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-6 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
from
|
||||
<a
|
||||
id="footer-elektrine"
|
||||
href="https://elektrine.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
elektrine
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label="Footer"
|
||||
class="grid grid-cols-2 gap-x-8 gap-y-8 text-sm sm:grid-cols-3 lg:gap-x-16"
|
||||
>
|
||||
<div>
|
||||
<h2 class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Registry
|
||||
</h2>
|
||||
<%!-- Mirrors the primary nav, in the same order. --%>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li>
|
||||
<.link navigate={~p"/"} class="text-ink-muted transition hover:text-ink">
|
||||
Registry
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/explore"} class="text-ink-muted transition hover:text-ink">
|
||||
Explore
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/infestations"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Infestations
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/jobs"} class="text-ink-muted transition hover:text-ink">
|
||||
Jobs
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/bounties"} class="text-ink-muted transition hover:text-ink">
|
||||
Contracts
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/agents"} class="text-ink-muted transition hover:text-ink">
|
||||
Agents
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/leaderboard"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Leaderboard
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Platform
|
||||
</h2>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li>
|
||||
<.link navigate={~p"/pricing"} class="text-ink-muted transition hover:text-ink">
|
||||
Pricing
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/services/disclosure"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Managed disclosure
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/policies/disclosure"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Disclosure policy
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/policies/content"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Content policy
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/.well-known/security.txt"
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
security.txt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 sm:col-span-1">
|
||||
<h2 class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Account
|
||||
</h2>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/moderation/report"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Report content
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={
|
||||
@current_scope.account_state == "active" &&
|
||||
@current_scope.platform_role in ["moderator", "admin"]
|
||||
}>
|
||||
<.link
|
||||
navigate={~p"/moderation/queue"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Moderation queue
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={@current_scope.platform_role == "admin"}>
|
||||
<.link
|
||||
id="footer-admin-dashboard"
|
||||
navigate={~p"/admin"}
|
||||
class={["text-signal transition hover:text-ink"]}
|
||||
>
|
||||
Administration
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/accounts/settings"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/accounts/log-out"}
|
||||
method="delete"
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Sign out
|
||||
</.link>
|
||||
</li>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/accounts/register"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Join Tarakan
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
>
|
||||
Sign in
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :path, :string, required: true
|
||||
attr :current, :string, default: nil
|
||||
attr :label, :string, required: true
|
||||
|
||||
# Server-rendered highlight. This used to be a JavaScript hook that toggled
|
||||
# classes after paint, which meant every page rendered with nothing active for
|
||||
# a frame, and dead routes like /pricing never highlighted at all because they
|
||||
# have no LiveSocket to run the hook.
|
||||
defp nav_link(assigns) do
|
||||
assigns =
|
||||
assign(assigns, :active, TarakanWeb.CurrentPath.active?(assigns.current, assigns.path))
|
||||
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@path}
|
||||
aria-current={@active && "page"}
|
||||
class={[
|
||||
"inline-flex shrink-0 items-center border-r-2 border-rule px-5 font-mono",
|
||||
"text-[11px] uppercase tracking-[0.12em] transition hover:bg-panel hover:text-ink",
|
||||
@active && "bg-panel text-ink shadow-[inset_0_-2px_0_0_var(--color-signal)]",
|
||||
!@active && "text-ink-faint"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :path, :string, required: true
|
||||
attr :current, :string, default: nil
|
||||
attr :label, :string, required: true
|
||||
|
||||
defp mobile_nav_link(assigns) do
|
||||
assigns =
|
||||
assign(assigns, :active, TarakanWeb.CurrentPath.active?(assigns.current, assigns.path))
|
||||
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@path}
|
||||
aria-current={@active && "page"}
|
||||
class={[
|
||||
"block px-4 py-3.5 font-mono text-[11px] uppercase tracking-[0.12em]",
|
||||
"transition hover:bg-panel",
|
||||
@active && "bg-panel text-ink shadow-[inset_2px_0_0_0_var(--color-signal)]",
|
||||
!@active && "text-ink"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
auto_dismiss={false}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={
|
||||
show(".phx-client-error #client-error")
|
||||
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
|
||||
}
|
||||
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
auto_dismiss={false}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={
|
||||
show(".phx-server-error #server-error")
|
||||
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
|
||||
}
|
||||
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
69
lib/tarakan_web/components/layouts/root.html.heex
Normal file
69
lib/tarakan_web/components/layouts/root.html.heex
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<% meta_description =
|
||||
assigns[:meta_description] ||
|
||||
"Local agents. Public Reports. Commit-pinned security record."
|
||||
|
||||
page_title =
|
||||
case assigns[:page_title] do
|
||||
title when is_binary(title) and title != "" -> title <> " · Tarakan"
|
||||
_ -> "Tarakan · public security record"
|
||||
end
|
||||
|
||||
canonical =
|
||||
if assigns[:canonical_path],
|
||||
do: TarakanWeb.Endpoint.url() <> assigns[:canonical_path],
|
||||
else: nil
|
||||
|
||||
og_image = assigns[:og_image] || TarakanWeb.Endpoint.url() <> "/images/og-image.png" %>
|
||||
<meta name="description" content={meta_description} />
|
||||
<link :if={canonical} rel="canonical" href={canonical} />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f4" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
|
||||
<%!-- Open Graph / Twitter: organic share + crawler previews for the public record. --%>
|
||||
<meta property="og:site_name" content="Tarakan" />
|
||||
<meta property="og:type" content={assigns[:og_type] || "website"} />
|
||||
<meta property="og:title" content={page_title} />
|
||||
<meta property="og:description" content={meta_description} />
|
||||
<meta :if={canonical} property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={og_image} />
|
||||
<meta property="og:image:alt" content="Tarakan - public security record" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={page_title} />
|
||||
<meta name="twitter:description" content={meta_description} />
|
||||
<meta name="twitter:image" content={og_image} />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Tarakan findings"
|
||||
href={TarakanWeb.Endpoint.url() <> "/feeds/findings.xml"}
|
||||
/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Tarakan infestations"
|
||||
href={TarakanWeb.Endpoint.url() <> "/feeds/infestations.xml"}
|
||||
/>
|
||||
<.live_title default="Tarakan" suffix=" · Tarakan" phx-no-format>{assigns[:page_title]}</.live_title>
|
||||
<%!-- Blocking, and deliberately not deferred: it restores a manually
|
||||
chosen theme before the first paint. app.js is a deferred module and
|
||||
therefore always too late. --%>
|
||||
<script phx-track-static src={~p"/assets/js/theme.js"}>
|
||||
</script>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="module" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script :if={assigns[:json_ld]} type="application/ld+json">
|
||||
{Jason.encode!(assigns[:json_ld])}
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-ground selection:bg-signal/25 selection:text-ink">
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
309
lib/tarakan_web/components/repository_components.ex
Normal file
309
lib/tarakan_web/components/repository_components.ex
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
defmodule TarakanWeb.RepositoryComponents do
|
||||
@moduledoc """
|
||||
Shared repository page chrome.
|
||||
|
||||
Every repository-scoped page renders the same header at the same scale:
|
||||
status badge row, display-face title, canonical source link, host metadata,
|
||||
and the Code / Commits / Security tab rail. Page bodies differ; the frame does not.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import TarakanWeb.CoreComponents,
|
||||
only: [icon: 1, breadcrumbs: 1, handle_link: 1, notch_badge: 1]
|
||||
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@doc ~S'Formats a star count compactly (`1200` -> `"1.2k"`); `nil` -> `"0"`.'
|
||||
def compact_stars(count) when is_integer(count) and count >= 1000,
|
||||
do: "#{Float.round(count / 1000, 1)}k"
|
||||
|
||||
def compact_stars(count) when is_integer(count), do: Integer.to_string(count)
|
||||
def compact_stars(_count), do: "0"
|
||||
|
||||
attr :repository, Tarakan.Repositories.Repository, required: true
|
||||
attr :active_tab, :atom, required: true, values: [:code, :commits, :security]
|
||||
|
||||
def repository_header(assigns) do
|
||||
~H"""
|
||||
<header>
|
||||
<.breadcrumbs id="repository-breadcrumb">
|
||||
<:crumb navigate="/">registry</:crumb>
|
||||
<:crumb navigate={RepositoryPaths.repository_path(@repository)}>
|
||||
{@repository.owner}
|
||||
</:crumb>
|
||||
<:crumb navigate={RepositoryPaths.repository_path(@repository)}>
|
||||
{@repository.name}
|
||||
</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<.notch_badge
|
||||
id="repository-status"
|
||||
class={status_badge_class(@repository)}
|
||||
title={repository_status_title(@repository)}
|
||||
>
|
||||
{repository_status_label(@repository)}
|
||||
</.notch_badge>
|
||||
<.notch_badge :if={@repository.archived} class="text-signal">
|
||||
archived
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={@repository.managed_disclosure}
|
||||
id="repository-managed-disclosure"
|
||||
class="text-quote"
|
||||
title="Disclosure to the vendor is handled by Tarakan staff. Handling only - the public record is unchanged."
|
||||
>
|
||||
Managed disclosure
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col justify-between gap-4 lg:flex-row lg:items-end">
|
||||
<div class="min-w-0">
|
||||
<h1
|
||||
id="repository-name"
|
||||
class="break-words font-display text-2xl font-medium uppercase leading-tight tracking-[0.02em] text-ink sm:text-3xl"
|
||||
>
|
||||
<span class="text-ink-faint">{@repository.owner}/</span>{@repository.name}
|
||||
</h1>
|
||||
<p :if={@repository.description} class="mt-2 max-w-3xl text-sm leading-6 text-ink-muted">
|
||||
{@repository.description}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
:if={not Tarakan.Repositories.Repository.hosted?(@repository)}
|
||||
id="repository-source-link"
|
||||
href={@repository.canonical_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-2 font-mono text-xs text-ink-muted transition hover:text-signal"
|
||||
>
|
||||
<span class="min-w-0 break-all">{@repository.canonical_url}</span>
|
||||
<.icon name="hero-arrow-up-right" class="size-4 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="github-metadata"
|
||||
class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-[11px] text-ink-faint sm:gap-x-5 sm:text-xs"
|
||||
>
|
||||
<span :if={@repository.primary_language} class="inline-flex items-center gap-2">
|
||||
<span class="size-2 bg-signal"></span>
|
||||
{@repository.primary_language}
|
||||
</span>
|
||||
<span
|
||||
:if={not Tarakan.Repositories.Repository.hosted?(@repository)}
|
||||
class="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<.icon name="hero-star" class="size-3.5" /> {@repository.stars_count} stars
|
||||
</span>
|
||||
<span
|
||||
:if={not Tarakan.Repositories.Repository.hosted?(@repository)}
|
||||
class="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<.icon name="hero-code-bracket" class="size-3.5" /> {@repository.forks_count} forks
|
||||
</span>
|
||||
<span :if={Tarakan.Repositories.Repository.hosted?(@repository)}>hosted on tarakan</span>
|
||||
<span :if={@repository.default_branch}>default: {@repository.default_branch}</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<.icon name="hero-clock" class="size-3.5" />
|
||||
registered {Calendar.strftime(
|
||||
@repository.inserted_at,
|
||||
"%Y-%m-%d"
|
||||
)}
|
||||
</span>
|
||||
<span :if={@repository.submitted_by} id="repository-submitter">
|
||||
by <.handle_link handle={@repository.submitted_by.handle} class="text-ink-muted" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
id="repository-navigation"
|
||||
class="mt-5 flex items-end gap-0 overflow-x-auto overscroll-x-contain border-b border-rule text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
aria-label="Repository"
|
||||
>
|
||||
<.repository_tab
|
||||
id="repository-code-tab"
|
||||
active={@active_tab == :code}
|
||||
navigate={RepositoryPaths.repository_path(@repository)}
|
||||
icon="hero-code-bracket"
|
||||
label="Code"
|
||||
/>
|
||||
<.repository_tab
|
||||
id="repository-commits-tab"
|
||||
active={@active_tab == :commits}
|
||||
navigate={RepositoryPaths.repository_commits_path(@repository)}
|
||||
icon="hero-clock"
|
||||
label="Commits"
|
||||
/>
|
||||
<.repository_tab
|
||||
id="repository-overview-tab"
|
||||
active={@active_tab == :security}
|
||||
navigate={RepositoryPaths.repository_security_path(@repository)}
|
||||
icon="hero-shield-check"
|
||||
label="Security"
|
||||
>
|
||||
<span
|
||||
:if={security_tab_count(@repository)}
|
||||
class={[
|
||||
"rounded-full px-1.5 py-0.5 font-mono text-[10px] leading-none",
|
||||
security_tab_count_class(@repository)
|
||||
]}
|
||||
>
|
||||
{security_tab_count(@repository)}
|
||||
</span>
|
||||
</.repository_tab>
|
||||
</nav>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :active, :boolean, required: true
|
||||
attr :navigate, :string, required: true
|
||||
attr :icon, :string, required: true
|
||||
attr :label, :string, required: true
|
||||
slot :inner_block
|
||||
|
||||
# The active tab stays a link. It used to render as a span, which made the
|
||||
# most natural navigation on the page impossible: from a commit detail page
|
||||
# the Commits tab points at the commit list, and from a nested file the Code
|
||||
# tab points at the repository root, and neither could be clicked. A span is
|
||||
# also not focusable, so keyboard users tabbed straight past the section they
|
||||
# were in. aria-current carries the "you are here" meaning instead.
|
||||
defp repository_tab(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
id={@id}
|
||||
navigate={@navigate}
|
||||
aria-current={@active && "page"}
|
||||
class={[
|
||||
"-mb-px inline-flex shrink-0 items-center gap-2 border-b-2 px-3 py-3 transition sm:px-4",
|
||||
@active && "border-signal font-semibold text-ink",
|
||||
!@active && "border-transparent text-ink-muted hover:border-rule hover:text-ink"
|
||||
]}
|
||||
>
|
||||
<.icon name={@icon} class="size-4" /> {@label} {render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The scan's submitted Tarakan Scan Format document, pretty-printed for the
|
||||
raw-report block. Returns the stored text unchanged when it isn't JSON.
|
||||
"""
|
||||
def raw_report(%{raw_document: nil}), do: nil
|
||||
|
||||
def raw_report(%{raw_document: raw_document}) do
|
||||
Jason.Formatter.pretty_print(raw_document)
|
||||
rescue
|
||||
_not_json -> raw_document
|
||||
end
|
||||
|
||||
@doc """
|
||||
Short label for who may do a job, or how a submission was made.
|
||||
|
||||
Hybrid is agent draft with a person in the loop - not a description of
|
||||
Tarakan overall (the product is already human-owned).
|
||||
"""
|
||||
def provenance_label("agent"), do: "Agent"
|
||||
def provenance_label("human"), do: "Human"
|
||||
def provenance_label("hybrid"), do: "Agent + human"
|
||||
def provenance_label(other), do: other
|
||||
|
||||
@doc "Human-readable label for a review or task kind."
|
||||
def review_kind_label("code_review"), do: "Code review"
|
||||
def review_kind_label("threat_model"), do: "Threat model"
|
||||
def review_kind_label("privacy_review"), do: "Privacy review"
|
||||
def review_kind_label("business_logic"), do: "Business logic"
|
||||
def review_kind_label("verify_findings"), do: "Verify findings"
|
||||
def review_kind_label("write_fix"), do: "Write a fix"
|
||||
def review_kind_label("diff_review"), do: "Diff review"
|
||||
def review_kind_label("refute_finding"), do: "Refute a finding"
|
||||
def review_kind_label("reproduce_finding"), do: "Reproduce a finding"
|
||||
def review_kind_label("calibrate_severity"), do: "Re-score severity"
|
||||
def review_kind_label("synthesize_rule"), do: "Write a detector"
|
||||
def review_kind_label(other), do: other
|
||||
|
||||
@doc """
|
||||
Short status for repo headers and meta.
|
||||
|
||||
Avoids the useless lone label \"findings\" once every repo has some.
|
||||
Prefers open/verified counts and clear/unreviewed states.
|
||||
"""
|
||||
def repository_status_label(%{status: "unscanned"}), do: "No report"
|
||||
|
||||
def repository_status_label(%{open_findings_count: open, verified_findings_count: verified})
|
||||
when is_integer(open) and open > 0 do
|
||||
open_label = if open == 1, do: "1 open", else: "#{open} open"
|
||||
|
||||
if is_integer(verified) and verified > 0 do
|
||||
verified_label = if verified == 1, do: "1 verified", else: "#{verified} verified"
|
||||
"#{open_label} · #{verified_label}"
|
||||
else
|
||||
open_label
|
||||
end
|
||||
end
|
||||
|
||||
def repository_status_label(%{status: status}) when status in ["reviewed", "clear"],
|
||||
do: "Clear"
|
||||
|
||||
def repository_status_label(%{scan_count: n}) when is_integer(n) and n > 0, do: "Clear"
|
||||
def repository_status_label(_repository), do: "No report"
|
||||
|
||||
defp repository_status_title(%{open_findings_count: open, verified_findings_count: verified})
|
||||
when is_integer(open) and open > 0 do
|
||||
open_part =
|
||||
if open == 1,
|
||||
do: "1 unique finding still open",
|
||||
else: "#{open} unique findings still open"
|
||||
|
||||
if is_integer(verified) and verified > 0 do
|
||||
verified_part =
|
||||
if verified == 1,
|
||||
do: "1 independently verified",
|
||||
else: "#{verified} independently verified"
|
||||
|
||||
"#{open_part}; #{verified_part}"
|
||||
else
|
||||
open_part
|
||||
end
|
||||
end
|
||||
|
||||
defp repository_status_title(%{status: "unscanned"}),
|
||||
do: "No public review on the record yet"
|
||||
|
||||
defp repository_status_title(%{status: status}) when status in ["reviewed", "clear"],
|
||||
do: "Reported with no open findings"
|
||||
|
||||
defp repository_status_title(%{scan_count: n}) when is_integer(n) and n > 0,
|
||||
do: "Reported with no open findings"
|
||||
|
||||
defp repository_status_title(_repository), do: "No public review on the record yet"
|
||||
|
||||
defp status_badge_class(%{open_findings_count: open}) when is_integer(open) and open > 0,
|
||||
do: "text-signal"
|
||||
|
||||
defp status_badge_class(%{status: "unscanned"}), do: "text-ink-faint"
|
||||
|
||||
defp status_badge_class(%{status: status}) when status in ["reviewed", "clear"],
|
||||
do: "text-quote"
|
||||
|
||||
defp status_badge_class(_repository), do: "text-ink-muted"
|
||||
|
||||
# Tab pill: open count when > 0, else verified count, else nothing for unscanned.
|
||||
defp security_tab_count(%{open_findings_count: open}) when is_integer(open) and open > 0,
|
||||
do: open
|
||||
|
||||
defp security_tab_count(%{verified_findings_count: verified})
|
||||
when is_integer(verified) and verified > 0,
|
||||
do: verified
|
||||
|
||||
defp security_tab_count(%{scan_count: scans}) when is_integer(scans) and scans > 0, do: "ok"
|
||||
defp security_tab_count(_repository), do: nil
|
||||
|
||||
defp security_tab_count_class(%{open_findings_count: open}) when is_integer(open) and open > 0,
|
||||
do: "bg-signal text-ground"
|
||||
|
||||
defp security_tab_count_class(_repository), do: "bg-panel text-ink-muted border border-rule"
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue