13.0 KiB · text History 6929c1e
defmodule WaxxWeb.BoardLive.Settings do
  use WaxxWeb, :live_view

  alias Waxx.Kanban

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    user = socket.assigns.current_scope.user
    board_id = id

    case Kanban.get_board_for_user(board_id, user) do
      nil ->
        {:ok,
         socket
         |> put_flash(:error, "Board not found.")
         |> push_navigate(to: ~p"/boards")}

      board ->
        role = Kanban.role_for(board_id, user)

        socket =
          socket
          |> assign(:page_title, "#{board.name} · settings")
          |> assign(:board, board)
          |> assign(:role, role)
          |> assign(:members, Kanban.list_members(board_id))
          |> assign(:invites, Kanban.list_board_invites(board))
          |> assign(:invite_form, to_form(%{"role" => "editor", "note" => ""}, as: "invite"))
          |> assign(:board_form, to_form(Kanban.change_board(board)))
          |> assign(:subboards, Kanban.list_subboards(board))
          |> assign(:subboard_form, to_form(%{"name" => ""}, as: "subboard"))

        {:ok, socket}
    end
  end

  @impl true
  def handle_event("rename_board", %{"board" => params}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      case Kanban.update_board(socket.assigns.board, params) do
        {:ok, board} ->
          {:noreply,
           socket
           |> assign(:board, board)
           |> assign(:board_form, to_form(Kanban.change_board(board)))
           |> put_flash(:info, "Board updated.")}

        {:error, cs} ->
          {:noreply, assign(socket, :board_form, to_form(cs))}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can rename the board.")}
    end
  end

  def handle_event("create_invite", %{"invite" => params}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      case Kanban.create_board_invite(
             socket.assigns.board,
             socket.assigns.current_scope.user,
             params
           ) do
        {:ok, _invite} ->
          invites = Kanban.list_board_invites(socket.assigns.board)
          {:noreply, socket |> assign(:invites, invites) |> put_flash(:info, "Invite created.")}

        {:error, _cs} ->
          {:noreply, put_flash(socket, :error, "Could not create invite.")}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can mint invites.")}
    end
  end

  def handle_event("revoke_invite", %{"id" => id}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      invite = Enum.find(socket.assigns.invites, &(&1.id == id))

      if invite do
        {:ok, _} = Kanban.revoke_board_invite(invite)
        invites = Kanban.list_board_invites(socket.assigns.board)
        {:noreply, assign(socket, :invites, invites)}
      else
        {:noreply, socket}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can revoke invites.")}
    end
  end

  def handle_event("update_member_role", %{"membership_id" => id, "role" => role}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      m = Enum.find(socket.assigns.members, &(&1.id == id))

      cond do
        is_nil(m) ->
          {:noreply, socket}

        m.role == "owner" ->
          {:noreply, put_flash(socket, :error, "Can't change the owner's role.")}

        true ->
          {:ok, _} = Kanban.update_member_role(m, role)
          members = Kanban.list_members(socket.assigns.board.id)
          {:noreply, assign(socket, :members, members)}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can change roles.")}
    end
  end

  def handle_event("remove_member", %{"id" => id}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      m = Enum.find(socket.assigns.members, &(&1.id == id))

      cond do
        is_nil(m) ->
          {:noreply, socket}

        m.role == "owner" ->
          {:noreply, put_flash(socket, :error, "Can't remove the owner.")}

        true ->
          {:ok, _} = Kanban.remove_member(m)
          members = Kanban.list_members(socket.assigns.board.id)
          {:noreply, assign(socket, :members, members)}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can remove members.")}
    end
  end

  def handle_event("create_subboard", %{"subboard" => params}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      case Kanban.create_subboard(socket.assigns.board, params) do
        {:ok, _} ->
          {:noreply,
           socket
           |> assign(:subboards, Kanban.list_subboards(socket.assigns.board))
           |> assign(:subboard_form, to_form(%{"name" => ""}, as: "subboard"))
           |> put_flash(:info, "Subboard added.")}

        {:error, _} ->
          {:noreply, put_flash(socket, :error, "Could not add subboard.")}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can add subboards.")}
    end
  end

  def handle_event("delete_subboard", %{"id" => id}, socket) do
    if Kanban.can_manage?(socket.assigns.role) do
      sb = Enum.find(socket.assigns.subboards, &(&1.id == id))

      if sb do
        {:ok, _} = Kanban.delete_subboard(sb)

        {:noreply,
         socket
         |> assign(:subboards, Kanban.list_subboards(socket.assigns.board))
         |> put_flash(:info, "Subboard deleted.")}
      else
        {:noreply, socket}
      end
    else
      {:noreply, put_flash(socket, :error, "Only the owner can delete subboards.")}
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="max-w-3xl mx-auto py-6 space-y-8">
        <header>
          <.link navigate={~p"/boards/#{@board.id}"} class="text-xs opacity-60 hover:underline">
            ← Back to board
          </.link>
          <h1 class="text-2xl font-bold mt-1">
            {@board.name} <span class="text-base opacity-60">· settings</span>
          </h1>
          <p class="text-xs opacity-60 mt-1">
            Your role: <span class="badge badge-ghost">{@role}</span>
          </p>
        </header>

        <section>
          <h2 class="text-lg font-semibold mb-2">General</h2>
          <.form
            for={@board_form}
            id="board-form"
            phx-submit="rename_board"
            class="flex flex-col gap-2"
          >
            <.input field={@board_form[:name]} type="text" label="Name" required />
            <.input field={@board_form[:description]} type="text" label="Description" />
            <.input
              field={@board_form[:archive_terminal_after_days]}
              type="number"
              min="1"
              label="Archive after N days in terminal stage (leave blank to never archive)"
            />
            <.button class="btn btn-primary w-fit" disabled={not Kanban.can_manage?(@role)}>
              Save
            </.button>
          </.form>
        </section>

        <section>
          <h2 class="text-lg font-semibold mb-2">Members</h2>
          <ul class="flex flex-col gap-2">
            <li
              :for={m <- @members}
              id={"member-#{m.id}"}
              class="border border-base-300 rounded-box p-2 bg-base-200 flex items-center gap-3"
            >
              <span class="font-mono text-xs opacity-60 w-6 text-right">{m.user.id}</span>
              <span class="flex-1 truncate">
                {m.user.email || m.user.display_name || "anonymous"}
              </span>
              <%= if m.role == "owner" or not Kanban.can_manage?(@role) do %>
                <span class="badge badge-ghost">{m.role}</span>
              <% else %>
                <form phx-change="update_member_role" class="flex items-center gap-2">
                  <input type="hidden" name="membership_id" value={m.id} />
                  <select name="role" class="select select-xs select-bordered">
                    <option value="editor" selected={m.role == "editor"}>editor</option>
                    <option value="viewer" selected={m.role == "viewer"}>viewer</option>
                  </select>
                </form>
                <button
                  type="button"
                  phx-click="remove_member"
                  phx-value-id={m.id}
                  data-confirm="Remove this member from the board?"
                  class="btn btn-ghost btn-xs text-error"
                  title="Remove"
                >
                  <.icon name="hero-x-mark-micro" class="size-4" />
                </button>
              <% end %>
            </li>
          </ul>
        </section>

        <section>
          <h2 class="text-lg font-semibold mb-2">Board invites</h2>
          <p class="text-sm opacity-70 mb-3">
            Each link can be used once to join this board. Recipients must already have an
            account; the link grants membership, not registration.
          </p>

          <%= if Kanban.can_manage?(@role) do %>
            <.form
              for={@invite_form}
              id="invite-form"
              phx-submit="create_invite"
              class="grid grid-cols-1 sm:grid-cols-3 gap-2 items-end mb-4"
            >
              <.input
                field={@invite_form[:role]}
                type="select"
                label="Role"
                options={[{"editor", "editor"}, {"viewer", "viewer"}]}
              />
              <.input field={@invite_form[:note]} type="text" label="Note (optional)" />
              <.button class="btn btn-primary">Mint invite</.button>
            </.form>
          <% end %>

          <ul class="flex flex-col gap-2">
            <li :if={@invites == []} class="text-center text-sm opacity-60 py-4">
              No invites yet.
            </li>
            <li
              :for={i <- @invites}
              id={"binvite-#{i.id}"}
              class={[
                "border border-base-300 rounded-box p-3 flex items-center gap-3 bg-base-200",
                i.consumed_at && "opacity-60"
              ]}
            >
              <div class="flex-1 min-w-0">
                <div class="flex items-center gap-2 text-xs">
                  <span class="badge badge-ghost">{i.role}</span>
                  <%= cond do %>
                    <% i.consumed_at && i.consumed_by_id -> %>
                      <span class="badge badge-ghost">used</span>
                    <% i.consumed_at -> %>
                      <span class="badge badge-error">revoked</span>
                    <% true -> %>
                      <span class="badge badge-success">active</span>
                  <% end %>
                </div>
                <code class="block mt-1 text-xs font-mono truncate">
                  {url(~p"/b/#{i.token}")}
                </code>
                <p :if={i.consumed_by} class="text-xs opacity-70 mt-1">
                  Redeemed by <span class="font-mono">{i.consumed_by.email}</span>
                </p>
              </div>
              <button
                :if={is_nil(i.consumed_at) and Kanban.can_manage?(@role)}
                type="button"
                phx-click="revoke_invite"
                phx-value-id={i.id}
                class="btn btn-ghost btn-xs text-error"
              >
                <.icon name="hero-x-mark-micro" class="size-4" />
              </button>
            </li>
          </ul>
        </section>

        <section>
          <h2 class="text-lg font-semibold mb-2">Subboards</h2>
          <p class="text-sm opacity-70 mb-3">
            Subboards split the board into rows. The kanban view becomes a
            grid: subboards down the side, stages across the top. New cards
            land in the default row until you assign them to a subboard.
          </p>

          <ul class="flex flex-col gap-2 mb-4">
            <li :if={@subboards == []} class="text-sm opacity-60 py-2">
              No subboards yet — everything sits in the default row.
            </li>
            <li
              :for={sb <- @subboards}
              id={"subboard-#{sb.id}"}
              class="border border-base-300 rounded-box p-2 bg-base-200 flex items-center gap-3"
            >
              <.icon name="hero-bars-3-bottom-left-micro" class="size-4 opacity-60 shrink-0" />
              <span class="flex-1 font-medium truncate">{sb.name}</span>
              <button
                :if={Kanban.can_manage?(@role)}
                type="button"
                phx-click="delete_subboard"
                phx-value-id={sb.id}
                data-confirm="Delete this subboard? Cards in it fall back to the default row."
                class="btn btn-ghost btn-xs text-error"
                title="Delete"
              >
                <.icon name="hero-trash-micro" class="size-4" />
              </button>
            </li>
          </ul>

          <.form
            :if={Kanban.can_manage?(@role)}
            for={@subboard_form}
            id="subboard-form"
            phx-submit="create_subboard"
            class="flex items-end gap-2"
          >
            <.input field={@subboard_form[:name]} type="text" label="New subboard name" required />
            <.button class="btn btn-primary">Add</.button>
          </.form>
        </section>
      </div>
    </Layouts.app>
    """
  end
end