368 lines
13 KiB
Elixir
368 lines
13 KiB
Elixir
|
|
defmodule Rsh26poolWeb.VoteLive do
|
||
|
|
@moduledoc """
|
||
|
|
The voting page. Choices are shown in a per-viewer shuffled order and their
|
||
|
|
vote counts are hidden while voting is open. In grouping mode the participant
|
||
|
|
posts a single opinion and votes for the groups they want to join; once voting
|
||
|
|
closes they see their assigned group.
|
||
|
|
"""
|
||
|
|
use Rsh26poolWeb, :live_view
|
||
|
|
|
||
|
|
alias Rsh26pool.Voting
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def mount(%{"room_number" => number}, _session, socket) do
|
||
|
|
participant = socket.assigns.current_participant
|
||
|
|
room = number |> Voting.get_room_by_number() |> Voting.ensure_closed()
|
||
|
|
|
||
|
|
if connected?(socket), do: Voting.subscribe(room.id)
|
||
|
|
|
||
|
|
{:ok,
|
||
|
|
socket
|
||
|
|
|> assign(:room, room)
|
||
|
|
|> assign(:participant, participant)
|
||
|
|
|> assign(:page_title, "投票 - #{room.name}")
|
||
|
|
|> load_state()}
|
||
|
|
end
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def handle_event("vote", %{"id" => id}, socket) do
|
||
|
|
case Voting.vote(socket.assigns.participant, String.to_integer(id)) do
|
||
|
|
{:ok, _} ->
|
||
|
|
{:noreply, refresh_votes(socket)}
|
||
|
|
|
||
|
|
{:error, :already_voted} ->
|
||
|
|
{:noreply, refresh_votes(socket)}
|
||
|
|
|
||
|
|
{:error, :limit_reached} ->
|
||
|
|
{:noreply, put_flash(socket, :error, "投票できる数の上限に達しました。")}
|
||
|
|
|
||
|
|
{:error, :closed} ->
|
||
|
|
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
|
||
|
|
|
||
|
|
{:error, _} ->
|
||
|
|
{:noreply, put_flash(socket, :error, "投票できませんでした。")}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("unvote", %{"id" => id}, socket) do
|
||
|
|
case Voting.unvote(socket.assigns.participant, String.to_integer(id)) do
|
||
|
|
:ok ->
|
||
|
|
{:noreply, refresh_votes(socket)}
|
||
|
|
|
||
|
|
{:error, :closed} ->
|
||
|
|
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
|
||
|
|
|
||
|
|
{:error, _} ->
|
||
|
|
{:noreply, refresh_votes(socket)}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("post_idea", %{"idea" => %{"text" => text}}, socket) do
|
||
|
|
case Voting.create_idea(socket.assigns.room, socket.assigns.participant, %{"text" => text}) do
|
||
|
|
{:ok, _idea} ->
|
||
|
|
message = if socket.assigns.room.grouping_mode, do: "意見を保存しました。", else: "アイデアを投稿しました。"
|
||
|
|
{:noreply, socket |> put_flash(:info, message) |> load_state()}
|
||
|
|
|
||
|
|
{:error, :closed} ->
|
||
|
|
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
|
||
|
|
|
||
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||
|
|
{:noreply, assign(socket, :idea_form, to_form(changeset, as: :idea))}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def handle_info({:voting, :ideas_changed}, socket) do
|
||
|
|
{:noreply,
|
||
|
|
assign(
|
||
|
|
socket,
|
||
|
|
:ideas,
|
||
|
|
Voting.shuffled_ideas(socket.assigns.room, socket.assigns.participant.token)
|
||
|
|
)}
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_info({:voting, :room_updated}, socket) do
|
||
|
|
{:noreply, reload_room(socket)}
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_info({:voting, _event}, socket), do: {:noreply, socket}
|
||
|
|
|
||
|
|
## State helpers --------------------------------------------------------
|
||
|
|
|
||
|
|
defp load_state(socket) do
|
||
|
|
room = socket.assigns.room
|
||
|
|
participant = socket.assigns.participant
|
||
|
|
open? = Voting.room_open?(room)
|
||
|
|
|
||
|
|
socket
|
||
|
|
|> assign(:open?, open?)
|
||
|
|
|> assign(:votes_remaining, Voting.votes_remaining(room, participant))
|
||
|
|
|> assign(:voted_ids, Voting.voted_idea_ids(participant))
|
||
|
|
|> assign(:ideas, Voting.shuffled_ideas(room, participant.token))
|
||
|
|
|> assign_idea_form()
|
||
|
|
|> assign_results(open?)
|
||
|
|
end
|
||
|
|
|
||
|
|
defp refresh_votes(socket) do
|
||
|
|
participant = socket.assigns.participant
|
||
|
|
|
||
|
|
socket
|
||
|
|
|> assign(:voted_ids, Voting.voted_idea_ids(participant))
|
||
|
|
|> assign(:votes_remaining, Voting.votes_remaining(socket.assigns.room, participant))
|
||
|
|
end
|
||
|
|
|
||
|
|
defp reload_room(socket) do
|
||
|
|
room =
|
||
|
|
socket.assigns.room.room_number |> Voting.get_room_by_number() |> Voting.ensure_closed()
|
||
|
|
|
||
|
|
socket |> assign(:room, room) |> load_state()
|
||
|
|
end
|
||
|
|
|
||
|
|
defp assign_idea_form(socket) do
|
||
|
|
room = socket.assigns.room
|
||
|
|
|
||
|
|
if room.grouping_mode do
|
||
|
|
opinion = Voting.get_participant_idea(room, socket.assigns.participant)
|
||
|
|
text = if opinion, do: opinion.text, else: ""
|
||
|
|
|
||
|
|
socket
|
||
|
|
|> assign(:my_opinion, opinion)
|
||
|
|
|> assign(:idea_form, to_form(%{"text" => text}, as: :idea))
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
|> assign(:my_opinion, nil)
|
||
|
|
|> assign(:idea_form, to_form(%{"text" => ""}, as: :idea))
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp assign_results(socket, true) do
|
||
|
|
socket |> assign(:results, []) |> assign(:grouping, nil) |> assign(:my_group, nil)
|
||
|
|
end
|
||
|
|
|
||
|
|
defp assign_results(socket, false) do
|
||
|
|
room = socket.assigns.room
|
||
|
|
|
||
|
|
if room.grouping_mode do
|
||
|
|
grouping = Voting.group_assignments(room)
|
||
|
|
|
||
|
|
socket
|
||
|
|
|> assign(:grouping, grouping)
|
||
|
|
|> assign(:my_group, find_my_group(grouping, socket.assigns.participant))
|
||
|
|
|> assign(:results, [])
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
|> assign(:results, Voting.results(room))
|
||
|
|
|> assign(:grouping, nil)
|
||
|
|
|> assign(:my_group, nil)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp find_my_group(%{groups: groups}, participant) do
|
||
|
|
Enum.find(groups, fn g ->
|
||
|
|
g.leader.id == participant.id or Enum.any?(g.members, &(&1.id == participant.id))
|
||
|
|
end)
|
||
|
|
end
|
||
|
|
|
||
|
|
defp find_my_group(_grouping, _participant), do: nil
|
||
|
|
|
||
|
|
## Render ---------------------------------------------------------------
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def render(assigns) do
|
||
|
|
~H"""
|
||
|
|
<Layouts.app flash={@flash} current_participant={@current_participant}>
|
||
|
|
<.header>
|
||
|
|
{@room.name}
|
||
|
|
<:subtitle>
|
||
|
|
ルーム番号 <span class="font-mono font-semibold">{@room.room_number}</span>
|
||
|
|
<span :if={@room.grouping_mode} class="badge badge-accent badge-sm ml-2">グルーピングモード</span>
|
||
|
|
</:subtitle>
|
||
|
|
<:actions>
|
||
|
|
<span :if={@open?} class="badge badge-success gap-1">
|
||
|
|
<.icon name="hero-hand-raised" class="size-4" /> 投票受付中
|
||
|
|
</span>
|
||
|
|
<span :if={not @open?} class="badge badge-neutral gap-1">
|
||
|
|
<.icon name="hero-lock-closed" class="size-4" /> 終了
|
||
|
|
</span>
|
||
|
|
</:actions>
|
||
|
|
</.header>
|
||
|
|
|
||
|
|
<%= if @open? do %>
|
||
|
|
<div class="alert bg-base-200 border border-base-300">
|
||
|
|
<.icon name="hero-ticket" class="size-5 text-primary" />
|
||
|
|
<span>
|
||
|
|
残りの投票数:<span class="font-bold text-lg">{@votes_remaining}</span> / {@room.votes_per_user}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card bg-base-100 border border-base-200 shadow-sm">
|
||
|
|
<div class="card-body">
|
||
|
|
<h2 class="card-title text-base">
|
||
|
|
<%= if @room.grouping_mode do %>
|
||
|
|
あなたの意見(1つだけ投稿できます)
|
||
|
|
<% else %>
|
||
|
|
新しいアイデアを投稿
|
||
|
|
<% end %>
|
||
|
|
</h2>
|
||
|
|
<.form for={@idea_form} phx-submit="post_idea" id="idea-form" class="flex flex-col gap-2">
|
||
|
|
<.input
|
||
|
|
field={@idea_form[:text]}
|
||
|
|
type="text"
|
||
|
|
placeholder={if @room.grouping_mode, do: "例:みんなで公園を掃除したい", else: "例:焼肉に行きたい"}
|
||
|
|
/>
|
||
|
|
<div class="flex justify-end">
|
||
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
||
|
|
<.icon name="hero-plus" class="size-4" />
|
||
|
|
{if @room.grouping_mode and @my_opinion, do: "意見を更新", else: "投稿する"}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</.form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<h2 class="font-semibold mb-2 flex items-center gap-2">
|
||
|
|
<.icon name="hero-hand-thumb-up" class="size-5 text-primary" />
|
||
|
|
{if @room.grouping_mode, do: "参加したいグループに投票", else: "アイデアに投票"}
|
||
|
|
</h2>
|
||
|
|
|
||
|
|
<p :if={@ideas == []} class="text-base-content/60 text-sm py-6 text-center">
|
||
|
|
まだ投稿がありません。最初の一つを投稿してみましょう。
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<ul class="space-y-2">
|
||
|
|
<li
|
||
|
|
:for={idea <- @ideas}
|
||
|
|
id={"idea-#{idea.id}"}
|
||
|
|
class="flex items-center justify-between gap-3 p-3 rounded-box border border-base-200 bg-base-100"
|
||
|
|
>
|
||
|
|
<div class="min-w-0">
|
||
|
|
<p class="truncate">{idea.text}</p>
|
||
|
|
<p :if={@room.grouping_mode} class="text-xs text-base-content/60">
|
||
|
|
提案者:{idea.participant.name}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<%= if MapSet.member?(@voted_ids, idea.id) do %>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
phx-click="unvote"
|
||
|
|
phx-value-id={idea.id}
|
||
|
|
class="btn btn-success btn-sm gap-1 shrink-0"
|
||
|
|
>
|
||
|
|
<.icon name="hero-check-badge" class="size-4" /> 投票済み
|
||
|
|
</button>
|
||
|
|
<% else %>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
phx-click="vote"
|
||
|
|
phx-value-id={idea.id}
|
||
|
|
disabled={@votes_remaining <= 0}
|
||
|
|
class="btn btn-outline btn-sm shrink-0"
|
||
|
|
>
|
||
|
|
投票する
|
||
|
|
</button>
|
||
|
|
<% end %>
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<% else %>
|
||
|
|
<div class="alert alert-info">
|
||
|
|
<.icon name="hero-lock-closed" class="size-5" />
|
||
|
|
<span>この投票は終了しました。</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<%= if @room.grouping_mode do %>
|
||
|
|
<.grouping_results grouping={@grouping} my_group={@my_group} participant={@participant} />
|
||
|
|
<% else %>
|
||
|
|
<.normal_results results={@results} />
|
||
|
|
<% end %>
|
||
|
|
<% end %>
|
||
|
|
</Layouts.app>
|
||
|
|
"""
|
||
|
|
end
|
||
|
|
|
||
|
|
## Result components ----------------------------------------------------
|
||
|
|
|
||
|
|
attr :results, :list, required: true
|
||
|
|
|
||
|
|
defp normal_results(assigns) do
|
||
|
|
~H"""
|
||
|
|
<div>
|
||
|
|
<h2 class="font-semibold mb-2 flex items-center gap-2">
|
||
|
|
<.icon name="hero-trophy" class="size-5 text-warning" /> 結果
|
||
|
|
</h2>
|
||
|
|
<p :if={@results == []} class="text-base-content/60 text-sm">投稿はありませんでした。</p>
|
||
|
|
<ol class="space-y-2">
|
||
|
|
<li
|
||
|
|
:for={{result, index} <- Enum.with_index(@results)}
|
||
|
|
class={[
|
||
|
|
"flex items-center justify-between gap-3 p-3 rounded-box border",
|
||
|
|
index == 0 && "border-warning bg-warning/10",
|
||
|
|
index != 0 && "border-base-200 bg-base-100"
|
||
|
|
]}
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-3 min-w-0">
|
||
|
|
<span class="font-bold text-lg w-6 text-center">{index + 1}</span>
|
||
|
|
<span class="truncate">{result.idea.text}</span>
|
||
|
|
</div>
|
||
|
|
<span class="badge badge-lg gap-1">
|
||
|
|
<.icon name="hero-hand-thumb-up" class="size-4" /> {result.count}
|
||
|
|
</span>
|
||
|
|
</li>
|
||
|
|
</ol>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
end
|
||
|
|
|
||
|
|
attr :grouping, :map, required: true
|
||
|
|
attr :my_group, :map, default: nil
|
||
|
|
attr :participant, :map, required: true
|
||
|
|
|
||
|
|
defp grouping_results(assigns) do
|
||
|
|
~H"""
|
||
|
|
<div class="space-y-4">
|
||
|
|
<div :if={@my_group} class="card bg-primary/10 border border-primary">
|
||
|
|
<div class="card-body py-4">
|
||
|
|
<p class="text-sm text-base-content/70">あなたのグループ</p>
|
||
|
|
<p class="text-lg font-bold flex items-center gap-2">
|
||
|
|
<.icon name="hero-user-group" class="size-5 text-primary" />
|
||
|
|
{@my_group.leader.name} さんのグループ
|
||
|
|
</p>
|
||
|
|
<p class="text-sm">テーマ:{@my_group.opinion && @my_group.opinion.text}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p :if={@grouping.groups == []} class="text-base-content/70 text-sm">
|
||
|
|
リーダーになる人が決まりませんでした(十分な票を集めた意見がありません)。
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
||
|
|
<div
|
||
|
|
:for={group <- @grouping.groups}
|
||
|
|
class="card bg-base-100 border border-base-200 shadow-sm"
|
||
|
|
>
|
||
|
|
<div class="card-body py-4">
|
||
|
|
<h3 class="font-bold flex items-center gap-2">
|
||
|
|
<.icon name="hero-user-group" class="size-5 text-primary" />
|
||
|
|
{group.leader.name} グループ
|
||
|
|
</h3>
|
||
|
|
<p class="text-sm text-base-content/70">
|
||
|
|
テーマ:{group.opinion && group.opinion.text}
|
||
|
|
</p>
|
||
|
|
<ul class="mt-2 space-y-1 text-sm">
|
||
|
|
<li class="flex items-center gap-2 font-semibold">
|
||
|
|
<.icon name="hero-trophy" class="size-4 text-warning" /> {group.leader.name}(リーダー)
|
||
|
|
</li>
|
||
|
|
<li :for={member <- group.members} class="flex items-center gap-2 pl-6">
|
||
|
|
{member.name}
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
end
|
||
|
|
end
|