Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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>

View 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