"""
@@ -89,7 +89,7 @@ defmodule AlgoraWeb.Components.UI.Card do
def card_footer(assigns) do
~H"""
-
+
{render_slot(@inner_block)}
"""
diff --git a/lib/algora_web/controllers/og_image_controller.ex b/lib/algora_web/controllers/og_image_controller.ex
index da99c408e..0aae535da 100644
--- a/lib/algora_web/controllers/og_image_controller.ex
+++ b/lib/algora_web/controllers/og_image_controller.ex
@@ -7,7 +7,7 @@ defmodule AlgoraWeb.OGImageController do
@opts [type: "png", width: 1200, height: 630, scale_factor: 1]
- @max_age 600
+ defp max_age, do: Algora.config([AlgoraWeb.OGImageController, :max_age])
def generate(conn, %{"path" => path}) do
object_path = Path.join(["og"] ++ path ++ ["og.png"])
@@ -49,7 +49,7 @@ defmodule AlgoraWeb.OGImageController do
{_, last_modified} ->
case DateTime.from_iso8601(convert_to_iso8601(last_modified)) do
{:ok, modified_at, _} ->
- DateTime.diff(DateTime.utc_now(), modified_at, :second) > @max_age
+ DateTime.diff(DateTime.utc_now(), modified_at, :second) > max_age()
_error ->
true
@@ -86,7 +86,7 @@ defmodule AlgoraWeb.OGImageController do
Task.start(fn ->
Algora.S3.upload(body, object_path,
content_type: "image/png",
- cache_control: "public, max-age=#{@max_age}"
+ cache_control: "public, max-age=#{max_age()}"
)
File.rm(filepath)
diff --git a/lib/algora_web/live/bounty_live.ex b/lib/algora_web/live/bounty_live.ex
new file mode 100644
index 000000000..92e08af83
--- /dev/null
+++ b/lib/algora_web/live/bounty_live.ex
@@ -0,0 +1,797 @@
+defmodule AlgoraWeb.BountyLive do
+ @moduledoc false
+ use AlgoraWeb, :live_view
+
+ import Ecto.Changeset
+ import Ecto.Query
+
+ alias Algora.Accounts
+ alias Algora.Admin
+ alias Algora.Bounties
+ alias Algora.Bounties.Bounty
+ alias Algora.Bounties.LineItem
+ alias Algora.Chat
+ alias Algora.Repo
+ alias Algora.Util
+ alias Algora.Workspace
+
+ require Logger
+
+ defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}]
+
+ defmodule RewardBountyForm do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key false
+ embedded_schema do
+ field :amount, Algora.Types.USD
+ field :github_handle, :string
+ field :tip_percentage, :decimal
+ end
+
+ def changeset(form, attrs) do
+ form
+ |> cast(attrs, [:amount, :tip_percentage, :github_handle])
+ |> validate_required([:amount, :github_handle])
+ |> validate_number(:tip_percentage, greater_than_or_equal_to: 0)
+ |> Algora.Validations.validate_money_positive(:amount)
+ end
+ end
+
+ defmodule ExclusiveBountyForm do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key false
+ embedded_schema do
+ field :github_handle, :string
+ field :deadline, :date
+ end
+
+ def changeset(form, attrs) do
+ form
+ |> cast(attrs, [:github_handle, :deadline])
+ |> validate_required([:github_handle])
+ |> Algora.Validations.validate_date_in_future(:deadline)
+ end
+ end
+
+ @impl true
+ def mount(%{"id" => bounty_id}, _session, socket) do
+ bounty =
+ Bounty
+ |> Repo.get!(bounty_id)
+ |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]])
+
+ ticket_ref = %{
+ owner: bounty.ticket.repository.user.provider_login,
+ repo: bounty.ticket.repository.name,
+ number: bounty.ticket.number
+ }
+
+ socket
+ |> assign(:bounty, bounty)
+ |> assign(:ticket_ref, ticket_ref)
+ |> on_mount(bounty)
+ end
+
+ @impl true
+ def mount(%{"repo_owner" => repo_owner, "repo_name" => repo_name, "number" => number}, _session, socket) do
+ number = String.to_integer(number)
+
+ ticket_ref = %{owner: repo_owner, repo: repo_name, number: number}
+
+ bounty =
+ from(b in Bounty,
+ join: t in assoc(b, :ticket),
+ join: r in assoc(t, :repository),
+ join: u in assoc(r, :user),
+ where: u.provider == "github",
+ where: u.provider_login == ^repo_owner,
+ where: r.name == ^repo_name,
+ where: t.number == ^number,
+ # TODO: pool bounties
+ limit: 1,
+ order_by: [asc: b.inserted_at]
+ )
+ |> Repo.one()
+ |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]])
+
+ socket
+ |> assign(:bounty, bounty)
+ |> assign(:ticket_ref, ticket_ref)
+ |> on_mount(bounty)
+ end
+
+ defp on_mount(socket, bounty) do
+ debits = Enum.filter(bounty.transactions, &(&1.type == :debit and &1.status == :succeeded))
+
+ total_paid =
+ debits
+ |> Enum.map(& &1.net_amount)
+ |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2))
+
+ ticket_body_html = Algora.Markdown.render(bounty.ticket.description)
+
+ reward_changeset =
+ RewardBountyForm.changeset(%RewardBountyForm{}, %{
+ tip_percentage: 0,
+ amount: bounty.amount
+ })
+
+ exclusive_changeset = ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, %{})
+
+ {:ok, thread} = Chat.get_or_create_bounty_thread(bounty)
+ messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender)
+ participants = thread.id |> Chat.list_participants() |> Repo.preload(:user)
+
+ if connected?(socket) do
+ Chat.subscribe(thread.id)
+ end
+
+ share_url =
+ url(
+ ~p"/#{socket.assigns.ticket_ref.owner}/#{socket.assigns.ticket_ref.repo}/issues/#{socket.assigns.ticket_ref.number}"
+ )
+
+ {:ok,
+ socket
+ |> assign(:share_url, share_url)
+ |> assign(:page_title, bounty.ticket.title)
+ |> assign(:ticket, bounty.ticket)
+ |> assign(:total_paid, total_paid)
+ |> assign(:ticket_body_html, ticket_body_html)
+ |> assign(:show_reward_modal, false)
+ |> assign(:show_exclusive_modal, false)
+ |> assign(:selected_context, nil)
+ |> assign(:recipient, nil)
+ |> assign(:line_items, [])
+ |> assign(:thread, thread)
+ |> assign(:messages, messages)
+ |> assign(:participants, participants)
+ |> assign(:reward_form, to_form(reward_changeset))
+ |> assign(:exclusive_form, to_form(exclusive_changeset))
+ |> assign_exclusives(bounty.shared_with)
+ |> assign_line_items()}
+ end
+
+ @impl true
+ def handle_params(_params, _url, %{assigns: %{current_user: nil}} = socket) do
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_params(_params, _url, socket) do
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_info(%Chat.MessageCreated{message: message, participant: participant}, socket) do
+ socket =
+ if message.id in Enum.map(socket.assigns.messages, & &1.id),
+ do: socket,
+ else: Phoenix.Component.update(socket, :messages, &(&1 ++ [message]))
+
+ socket =
+ if participant.id in Enum.map(socket.assigns.participants, & &1.id),
+ do: socket,
+ else: Phoenix.Component.update(socket, :participants, &(&1 ++ [participant]))
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("send_message", %{"message" => content}, socket) do
+ {:ok, message} =
+ Chat.send_message(
+ socket.assigns.thread.id,
+ socket.assigns.current_user.id,
+ content
+ )
+
+ message = Repo.preload(message, :sender)
+
+ {:noreply,
+ socket
+ |> Phoenix.Component.update(:messages, &(&1 ++ [message]))
+ |> push_event("clear-input", %{selector: "#message-input"})}
+ end
+
+ @impl true
+ def handle_event("reward", _params, socket) do
+ {:noreply, assign(socket, :show_reward_modal, true)}
+ end
+
+ @impl true
+ def handle_event("exclusive", _params, socket) do
+ {:noreply, assign(socket, :show_exclusive_modal, true)}
+ end
+
+ @impl true
+ def handle_event("close_drawer", _params, socket) do
+ {:noreply, close_drawers(socket)}
+ end
+
+ @impl true
+ def handle_event("validate_reward", %{"reward_bounty_form" => params}, socket) do
+ {:noreply,
+ socket
+ |> assign(:reward_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params)))
+ |> assign_line_items()}
+ end
+
+ @impl true
+ def handle_event("assign_line_items", %{"reward_bounty_form" => params}, socket) do
+ {:noreply,
+ socket
+ |> assign_recipient(params["github_handle"])
+ |> assign_line_items()}
+ end
+
+ @impl true
+ def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do
+ changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params)
+
+ case apply_action(changeset, :save) do
+ {:ok, _data} ->
+ case reward_bounty(socket, socket.assigns.bounty, changeset) do
+ {:ok, session_url} ->
+ {:noreply, redirect(socket, external: session_url)}
+
+ {:error, reason} ->
+ Logger.error("Failed to create payment session: #{inspect(reason)}")
+ {:noreply, put_flash(socket, :error, "Something went wrong")}
+ end
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :reward_form, to_form(changeset))}
+ end
+ end
+
+ @impl true
+ def handle_event("validate_exclusive", %{"exclusive_bounty_form" => params}, socket) do
+ {:noreply, assign(socket, :exclusive_form, to_form(ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, params)))}
+ end
+
+ @impl true
+ def handle_event("share_exclusive", %{"exclusive_bounty_form" => params}, socket) do
+ changeset = ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, params)
+ bounty = socket.assigns.bounty
+
+ case apply_action(changeset, :save) do
+ {:ok, data} ->
+ with {:ok, token} <- Accounts.get_access_token(socket.assigns.current_user),
+ {:ok, user} <- Workspace.ensure_user(token, data.github_handle),
+ shared_with = Enum.uniq(bounty.shared_with ++ [user.provider_id]),
+ {:ok, bounty} <-
+ bounty
+ |> Bounty.settings_changeset(%{
+ shared_with: shared_with,
+ deadline: if(data.deadline, do: DateTime.new!(data.deadline, ~T[00:00:00], "Etc/UTC"))
+ })
+ |> Repo.update() do
+ {:noreply,
+ socket
+ |> put_flash(:info, "Bounty shared!")
+ |> assign(:bounty, bounty)
+ |> assign_exclusives(shared_with)
+ |> close_drawers()}
+ else
+ nil ->
+ {:noreply, put_flash(socket, :error, "User not found")}
+
+ {:error, reason} ->
+ Logger.error("Failed to share bounty: #{inspect(reason)}")
+ {:noreply, put_flash(socket, :error, "Something went wrong")}
+ end
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :exclusive_form, to_form(changeset))}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.scroll_area class="sm:h-[calc(100svh-96px)] flex-1 pr-6">
+
+ <.card>
+ <.card_content>
+
+
+ <.avatar class="h-12 w-12 sm:h-20 sm:w-20 rounded-lg sm:rounded-2xl">
+ <.avatar_image src={@ticket.repository.user.avatar_url} />
+ <.avatar_fallback>
+ {Util.initials(@ticket.repository.user.provider_login)}
+
+
+
+ <.link
+ href={@ticket.url}
+ target="_blank"
+ rel="noopener"
+ class="block text-xl sm:text-3xl font-semibold text-foreground/90 hover:underline"
+ >
+ {@ticket.title}
+
+ <.link
+ href={@ticket.url}
+ target="_blank"
+ rel="noopener"
+ class="block text-base font-display sm:text-xl font-medium text-muted-foreground hover:underline"
+ >
+ {@ticket.repository.user.provider_login}/{@ticket.repository.name}#{@ticket.number}
+
+
+
+
+
+ {Money.to_string!(@bounty.amount)}
+
+ <.button phx-click="reward">
+ Reward
+
+
+
+
+
+
+ <.card class="flex flex-col items-between justify-center">
+ <.card_content>
+
+
+ <.card_title>
+ Share on socials
+
+
+ <.social_share_button
+ id="twitter-share-url"
+ icon="tabler-brand-x"
+ value={@share_url}
+ />
+ <.social_share_button
+ id="reddit-share-url"
+ icon="tabler-brand-reddit"
+ value={@share_url}
+ />
+ <.social_share_button
+ id="linkedin-share-url"
+ icon="tabler-brand-linkedin"
+ value={@share_url}
+ />
+ <.social_share_button
+ id="hackernews-share-url"
+ icon="tabler-brand-ycombinator"
+ value={@share_url}
+ />
+
+
+
+
+
+
+ <.card class="flex flex-col items-between justify-center">
+ <.card_content>
+
+
+ <.card_title>
+ Exclusives
+
+
+
+ <%= if @bounty.deadline do %>
+ Expires on {Calendar.strftime(@bounty.deadline, "%b %d, %Y")}
+ <.button
+ variant="ghost"
+ size="icon-sm"
+ phx-click="exclusive"
+ class="group h-6 w-6"
+ >
+ <.icon
+ name="tabler-pencil"
+ class="h-4 w-4 text-muted-foreground group-hover:text-foreground"
+ />
+
+ <% else %>
+
+ Add a deadline
+
+ <% end %>
+
+
+ <.button variant="secondary" phx-click="exclusive" class="mt-3">
+ <.icon name="tabler-user-plus" class="size-5 mr-2 -ml-1" /> Add
+
+
+
+ <%= for user <- @exclusives do %>
+
+
+
+ <.avatar>
+ <.avatar_image src={user.avatar_url} />
+ <.avatar_fallback>{Util.initials(user.name)}
+
+
+
+ {user.name}
+
+
+ @{user.provider_login}
+
+
+
+
+
+ <% end %>
+
+
+
+
+
+ <.card>
+ <.card_header>
+ <.card_title>
+ Description
+
+
+ <.card_content>
+
+ {Phoenix.HTML.raw(@ticket_body_html)}
+
+
+
+
+
+
+
+
+
+
+ Contributor chat
+
+
+ <.avatar_group srcs={Enum.map(@participants, & &1.user.avatar_url)} />
+
+
+
+ <.scroll_area
+ class="flex h-full flex-1 flex-col-reverse gap-6 p-4"
+ id="messages-container"
+ phx-hook="ScrollToBottom"
+ >
+
+ <%= for {date, messages} <- @messages
+ |> Enum.group_by(fn msg ->
+ case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do
+ 0 -> "Today"
+ 1 -> "Yesterday"
+ n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A")
+ _ -> Calendar.strftime(msg.inserted_at, "%b %d")
+ end
+ end)
+ |> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %>
+
+
+
+ <%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %>
+
+ <.avatar class="h-8 w-8">
+ <.avatar_image src={message.sender.avatar_url} />
+ <.avatar_fallback>
+ {Util.initials(message.sender.name)}
+
+
+
+ {message.content}
+
+ {message.inserted_at
+ |> DateTime.to_time()
+ |> Time.to_string()
+ |> String.slice(0..4)}
+
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
+
+ <.drawer
+ :if={@current_user}
+ show={@show_exclusive_modal}
+ on_cancel="close_drawer"
+ direction="right"
+ >
+ <.drawer_header>
+ <.drawer_title>Share
+ <.drawer_description>
+ Make this bounty exclusive to specific users
+
+
+ <.drawer_content class="mt-4">
+ <.form for={@exclusive_form} phx-change="validate_exclusive" phx-submit="share_exclusive">
+
+
+ <.input label="GitHub handle" field={@exclusive_form[:github_handle]} />
+ <.input type="date" label="Deadline" field={@exclusive_form[:deadline]} />
+
+
+ <.button variant="secondary" phx-click="close_drawer" type="button">
+ Cancel
+
+ <.button type="submit">
+ Submit
+
+
+
+
+
+
+ <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer">
+ <.drawer_header>
+ <.drawer_title>Reward Bounty
+ <.drawer_description>
+ You can pay the full bounty now or start with a partial amount - it's up to you!
+
+
+ <.drawer_content class="mt-4">
+ <.form for={@reward_form} phx-change="validate_reward" phx-submit="pay_with_stripe">
+
+
+ <.card>
+ <.card_header>
+ <.card_title>Payment Details
+
+ <.card_content>
+
+ <.input
+ label="Amount"
+ icon="tabler-currency-dollar"
+ field={@reward_form[:amount]}
+ />
+ <.input
+ label="GitHub handle"
+ field={@reward_form[:github_handle]}
+ phx-change="assign_line_items"
+ phx-debounce="500"
+ />
+
+ <.label>Tip
+
+ <.radio_group
+ class="grid grid-cols-4 gap-4"
+ field={@reward_form[:tip_percentage]}
+ options={tip_options()}
+ />
+
+
+
+
+
+ <.card>
+ <.card_header>
+ <.card_title>Payment Summary
+
+ <.card_content>
+
+ <%= for line_item <- @line_items do %>
+
+
-
+ <%= if line_item.image do %>
+ <.avatar>
+ <.avatar_image src={line_item.image} />
+ <.avatar_fallback>
+ {Util.initials(line_item.title)}
+
+
+ <% else %>
+
+ <% end %>
+
+
{line_item.title}
+
{line_item.description}
+
+
+
-
+ {Money.to_string!(line_item.amount)}
+
+
+ <% end %>
+
+
+
-
+
+
Total due
+
+
-
+ {LineItem.gross_amount(@line_items)}
+
+
+
+
+
+
+
+ <.button variant="secondary" phx-click="close_drawer" type="button">
+ Cancel
+
+ <.button type="submit">
+ Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
+
+
+
+
+
+
+ """
+ end
+
+ defp assign_recipient(socket, github_handle) do
+ case Workspace.ensure_user(Admin.token!(), github_handle) do
+ {:ok, recipient} ->
+ assign(socket, :recipient, recipient)
+
+ _ ->
+ assign(socket, :recipient, nil)
+ end
+ end
+
+ defp assign_line_items(socket) do
+ amount = calculate_final_amount(socket.assigns.reward_form.source)
+ recipient = socket.assigns.recipient
+ ticket_ref = socket.assigns.ticket_ref
+
+ line_items =
+ if recipient do
+ []
+ else
+ [
+ %LineItem{
+ amount: amount,
+ title: "Recipient",
+ image: ~p"/images/placeholder-avatar.png",
+ description: if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}")
+ }
+ ]
+ end ++
+ Bounties.generate_line_items(
+ %{
+ owner: socket.assigns.bounty.owner,
+ amount: amount
+ },
+ ticket_ref: ticket_ref,
+ recipient: recipient
+ )
+
+ assign(socket, :line_items, line_items)
+ end
+
+ defp reward_bounty(socket, bounty, changeset) do
+ final_amount = calculate_final_amount(changeset)
+
+ Bounties.reward_bounty(
+ %{owner: bounty.owner, amount: final_amount, bounty_id: bounty.id, claims: []},
+ ticket_ref: socket.assigns.ticket_ref,
+ recipient: socket.assigns.recipient
+ )
+ end
+
+ defp calculate_final_amount(data_or_changeset) do
+ tip_percentage = get_field(data_or_changeset, :tip_percentage) || Decimal.new(0)
+ amount = get_field(data_or_changeset, :amount) || Money.zero(:USD, no_fraction_if_integer: true)
+
+ multiplier = tip_percentage |> Decimal.div(100) |> Decimal.add(1)
+ Money.mult!(amount, multiplier)
+ end
+
+ defp social_share_button(assigns) do
+ ~H"""
+ <.button
+ id={@id}
+ phx-hook="CopyToClipboard"
+ data-value={@value}
+ variant="secondary"
+ phx-click={
+ %JS{}
+ |> JS.hide(
+ to: "##{@id}-copy-icon",
+ transition: {"transition-opacity", "opacity-100", "opacity-0"}
+ )
+ |> JS.show(
+ to: "##{@id}-check-icon",
+ transition: {"transition-opacity", "opacity-0", "opacity-100"}
+ )
+ }
+ class="size-6 sm:size-9 relative cursor-pointer text-foreground/90 hover:text-foreground bg-muted"
+ >
+ <.icon
+ id={@id <> "-copy-icon"}
+ name={@icon}
+ class="absolute inset-0 m-auto size-6 sm:size-6 flex items-center justify-center"
+ />
+ <.icon
+ id={@id <> "-check-icon"}
+ name="tabler-check"
+ class="absolute inset-0 m-auto hidden size-6 sm:size-6 items-center justify-center"
+ />
+
+ """
+ end
+
+ defp close_drawers(socket) do
+ socket
+ |> assign(:show_reward_modal, false)
+ |> assign(:show_exclusive_modal, false)
+ end
+
+ defp assign_exclusives(socket, shared_with) do
+ exclusives =
+ Enum.flat_map(shared_with, fn provider_id ->
+ case Workspace.ensure_user_by_provider_id(Admin.token!(), provider_id) do
+ {:ok, user} -> [user]
+ _ -> []
+ end
+ end)
+
+ assign(socket, :exclusives, exclusives)
+ end
+end
diff --git a/lib/algora_web/live/chat/thread_live.ex b/lib/algora_web/live/chat/thread_live.ex
index ad9175081..6fba11513 100644
--- a/lib/algora_web/live/chat/thread_live.ex
+++ b/lib/algora_web/live/chat/thread_live.ex
@@ -38,7 +38,7 @@ defmodule AlgoraWeb.Chat.ThreadLive do
end
@impl true
- def handle_info(%Message{} = message, socket) do
+ def handle_info(%{message: message}, socket) do
if message.id in Enum.map(socket.assigns.messages, & &1.id) do
{:noreply, socket}
else
diff --git a/lib/algora_web/live/contract/view_live.ex b/lib/algora_web/live/contract/view_live.ex
index 634c80dfa..6878d6ee9 100644
--- a/lib/algora_web/live/contract/view_live.ex
+++ b/lib/algora_web/live/contract/view_live.ex
@@ -510,7 +510,7 @@ defmodule AlgoraWeb.Contract.ViewLive do
end
end
- def handle_info(%Chat.Message{} = message, socket) do
+ def handle_info(%Chat.MessageCreated{message: message}, socket) do
if message.id in Enum.map(socket.assigns.messages, & &1.id) do
{:noreply, socket}
else
diff --git a/lib/algora_web/live/og/bounty_live.ex b/lib/algora_web/live/og/bounty_live.ex
index 28121731e..e854f3bfd 100644
--- a/lib/algora_web/live/og/bounty_live.ex
+++ b/lib/algora_web/live/og/bounty_live.ex
@@ -2,21 +2,25 @@ defmodule AlgoraWeb.OG.BountyLive do
@moduledoc false
use AlgoraWeb, :live_view
- alias Algora.Bounties
+ alias Algora.Bounties.Bounty
+ alias Algora.Repo
def mount(%{"id" => id}, _session, socket) do
- case Bounties.list_bounties(id: id) do
- [bounty | _] ->
- socket =
- socket
- |> assign(:bounty, bounty)
- |> assign(:ticket, bounty.ticket)
+ bounty =
+ Bounty
+ |> Repo.get!(id)
+ |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]])
- {:ok, socket}
+ ticket_ref = %{
+ owner: bounty.ticket.repository.user.provider_login,
+ repo: bounty.ticket.repository.name,
+ number: bounty.ticket.number
+ }
- [] ->
- {:ok, socket |> put_flash(:error, "Bounty not found") |> redirect(to: "/")}
- end
+ {:ok,
+ socket
+ |> assign(:bounty, bounty)
+ |> assign(:ticket_ref, ticket_ref)}
end
def render(assigns) do
@@ -26,19 +30,19 @@ defmodule AlgoraWeb.OG.BountyLive do
algora.io
- {@bounty.repository.name}#{@ticket.number}
+ {@bounty.ticket.repository.name}#{@bounty.ticket.number}
"""
end
diff --git a/lib/algora_web/live/org/repo_nav.ex b/lib/algora_web/live/org/repo_nav.ex
new file mode 100644
index 000000000..f42fcf2e1
--- /dev/null
+++ b/lib/algora_web/live/org/repo_nav.ex
@@ -0,0 +1,107 @@
+defmodule AlgoraWeb.Org.RepoNav do
+ @moduledoc false
+ use Phoenix.Component
+
+ import Phoenix.LiveView
+
+ alias Algora.Organizations
+ alias AlgoraWeb.OrgAuth
+
+ def on_mount(:default, %{"repo_owner" => repo_owner} = params, _session, socket) do
+ current_user = socket.assigns[:current_user]
+ current_org = Organizations.get_org_by(provider_login: repo_owner, provider: "github")
+ current_user_role = OrgAuth.get_user_role(current_user, current_org)
+
+ {:cont,
+ socket
+ |> assign(:screenshot?, not is_nil(params["screenshot"]))
+ |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""}))
+ |> assign(:current_org, current_org)
+ |> assign(:current_user_role, current_user_role)
+ |> assign(:nav, nav_items(current_org.handle, current_user_role))
+ |> assign(:contacts, [])
+ |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)}
+ end
+
+ defp handle_active_tab_params(_params, _url, socket) do
+ active_tab =
+ case {socket.view, socket.assigns.live_action} do
+ {AlgoraWeb.Org.DashboardLive, _} -> :dashboard
+ {AlgoraWeb.Org.HomeLive, _} -> :home
+ {AlgoraWeb.Org.BountiesLive, _} -> :bounties
+ {AlgoraWeb.Org.ProjectsLive, _} -> :projects
+ {AlgoraWeb.Project.ViewLive, _} -> :projects
+ {AlgoraWeb.Org.SettingsLive, _} -> :settings
+ {AlgoraWeb.Org.MembersLive, _} -> :members
+ {_, _} -> nil
+ end
+
+ {:cont, assign(socket, :active_tab, active_tab)}
+ end
+
+ def nav_items(org_handle, current_user_role) do
+ [
+ %{
+ title: "Overview",
+ items: build_nav_items(org_handle, current_user_role)
+ }
+ ]
+ end
+
+ defp build_nav_items(org_handle, current_user_role) do
+ Enum.filter(
+ [
+ %{
+ href: "/org/#{org_handle}",
+ tab: :dashboard,
+ icon: "tabler-sparkles",
+ label: "Dashboard",
+ roles: [:admin, :mod]
+ },
+ %{
+ href: "/org/#{org_handle}/home",
+ tab: :home,
+ icon: "tabler-home",
+ label: "Home",
+ roles: [:admin, :mod, :expert, :none]
+ },
+ %{
+ href: "/org/#{org_handle}/bounties",
+ tab: :bounties,
+ icon: "tabler-diamond",
+ label: "Bounties",
+ roles: [:admin, :mod, :expert, :none]
+ },
+ %{
+ href: "/org/#{org_handle}/leaderboard",
+ tab: :leaderboard,
+ icon: "tabler-trophy",
+ label: "Leaderboard",
+ roles: [:admin, :mod, :expert, :none]
+ },
+ %{
+ href: "/org/#{org_handle}/team",
+ tab: :team,
+ icon: "tabler-users",
+ label: "Team",
+ roles: [:admin, :mod, :expert, :none]
+ },
+ %{
+ href: "/org/#{org_handle}/transactions",
+ tab: :transactions,
+ icon: "tabler-credit-card",
+ label: "Transactions",
+ roles: [:admin]
+ },
+ %{
+ href: "/org/#{org_handle}/settings",
+ tab: :settings,
+ icon: "tabler-settings",
+ label: "Settings",
+ roles: [:admin]
+ }
+ ],
+ fn item -> current_user_role in item[:roles] end
+ )
+ end
+end
diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex
index 6e808824d..8bff44acb 100644
--- a/lib/algora_web/router.ex
+++ b/lib/algora_web/router.ex
@@ -88,6 +88,15 @@ defmodule AlgoraWeb.Router do
end
end
+ scope "/:repo_owner/:repo_name" do
+ live_session :repo,
+ layout: {AlgoraWeb.Layouts, :user},
+ on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Org.RepoNav] do
+ live "/issues/:number", BountyLive
+ live "/pull/:number", BountyLive
+ end
+ end
+
scope "/org/:org_handle" do
live_session :org,
layout: {AlgoraWeb.Layouts, :user},
@@ -95,6 +104,7 @@ defmodule AlgoraWeb.Router do
live "/", Org.DashboardLive, :index
live "/home", Org.HomeLive, :index
live "/bounties", Org.BountiesLive, :index
+ live "/bounties/:id", BountyLive, :index
live "/contracts/:id", Contract.ViewLive
live "/team", Org.TeamLive, :index
live "/leaderboard", Org.LeaderboardLive, :index
diff --git a/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs b/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs
new file mode 100644
index 000000000..57f052cbc
--- /dev/null
+++ b/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs
@@ -0,0 +1,9 @@
+defmodule Algora.Repo.Migrations.AddDeadlineToBounties do
+ use Ecto.Migration
+
+ def change do
+ alter table(:bounties) do
+ add :deadline, :utc_datetime_usec
+ end
+ end
+end
diff --git a/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs b/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs
new file mode 100644
index 000000000..290a1ba70
--- /dev/null
+++ b/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs
@@ -0,0 +1,11 @@
+defmodule Algora.Repo.Migrations.AddBountyIdToThreads do
+ use Ecto.Migration
+
+ def change do
+ alter table(:threads) do
+ add :bounty_id, :string, null: true
+ end
+
+ create unique_index(:threads, [:bounty_id])
+ end
+end