This commit is contained in:
2026-07-04 19:27:17 +09:00
parent 34cd5ad2e7
commit ba6a0b2c17
30 changed files with 2935 additions and 247 deletions
+237
View File
@@ -0,0 +1,237 @@
defmodule Rsh26poolWeb.ManageLive do
@moduledoc """
The owner's management page: rename the room, end voting, and watch results
(with vote counts) and participants update in real time. In grouping mode the
computed teams are shown once voting has ended.
"""
use Rsh26poolWeb, :live_view
alias Rsh26pool.JST
alias Rsh26pool.Voting
@impl true
def mount(%{"room_number" => number}, _session, socket) do
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, socket.assigns.current_participant)
|> assign(:page_title, "管理 - #{room.name}")
|> assign(:name_form, to_form(Voting.change_room_settings(room)))
|> load_state()}
end
@impl true
def handle_event("rename", %{"room" => params}, socket) do
case Voting.update_room_settings(socket.assigns.room, params) do
{:ok, room} ->
{:noreply,
socket
|> assign(:room, room)
|> assign(:name_form, to_form(Voting.change_room_settings(room)))
|> put_flash(:info, "ルーム名を変更しました。")}
{:error, changeset} ->
{:noreply, assign(socket, :name_form, to_form(changeset))}
end
end
def handle_event("close", _params, socket) do
{:ok, room} = Voting.close_room(socket.assigns.room)
{:noreply,
socket
|> assign(:room, room)
|> put_flash(:info, "投票を終了しました。")
|> load_state()}
end
@impl true
def handle_info({:voting, :room_updated}, socket) do
room =
socket.assigns.room.room_number |> Voting.get_room_by_number() |> Voting.ensure_closed()
{:noreply, socket |> assign(:room, room) |> load_state()}
end
def handle_info({:voting, _event}, socket) do
{:noreply, load_state(socket)}
end
## State ---------------------------------------------------------------
defp load_state(socket) do
room = socket.assigns.room
open? = Voting.room_open?(room)
socket
|> assign(:open?, open?)
|> assign(:results, Voting.results(room))
|> assign(:participants, Voting.list_participants(room))
|> assign(:grouping, if(not open? and room.grouping_mode, do: Voting.group_assignments(room)))
end
## Render --------------------------------------------------------------
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_participant={@current_participant}>
<.header>
管理ページ
<:subtitle>{@room.name}</: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>
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<p class="text-sm text-base-content/70">参加者に伝えるルーム番号</p>
<p class="text-3xl font-mono font-bold tracking-widest">{@room.room_number}</p>
<p class="text-sm text-base-content/60">
投票ページ:<span class="font-mono">{url(~p"/vote/#{@room.room_number}")}</span>
</p>
<p :if={@room.closes_at} class="text-sm text-base-content/70 flex items-center gap-1 mt-1">
<.icon name="hero-clock" class="size-4" /> 自動終了予定:{JST.format(@room.closes_at)}(日本時間)
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<h2 class="card-title text-base">
<.icon name="hero-pencil-square" class="size-5" /> ルーム名を変更
</h2>
<.form for={@name_form} phx-submit="rename" id="rename-form" class="flex flex-col gap-2">
<.input field={@name_form[:name]} type="text" />
<div class="flex justify-end">
<button type="submit" class="btn btn-primary btn-sm">保存</button>
</div>
</.form>
</div>
</div>
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<h2 class="card-title text-base">
<.icon name="hero-lock-closed" class="size-5" /> 投票の終了
</h2>
<%= if @open? do %>
<p class="text-sm text-base-content/70">
投票を終了すると、参加者は投票できなくなります。 <span :if={@room.grouping_mode}>終了時にグループ分けが行われます。</span>
</p>
<div class="flex justify-end">
<button
type="button"
phx-click="close"
data-confirm="投票を終了しますか?この操作は取り消せません。"
class="btn btn-error btn-sm"
>
投票を終了する
</button>
</div>
<% else %>
<p class="text-sm text-base-content/70">この投票は終了しています。</p>
<% end %>
</div>
</div>
</div>
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-trophy" class="size-5 text-warning" /> 現在の結果
<span class="badge badge-sm">{length(@results)} 件</span>
</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 && @results != [] && "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>
<div class="min-w-0">
<p class="truncate">{result.idea.text}</p>
<p :if={@room.grouping_mode} class="text-xs text-base-content/60">
提案者:{result.idea.participant.name}
</p>
</div>
</div>
<span class="badge badge-lg gap-1">
<.icon name="hero-hand-thumb-up" class="size-4" /> {result.count}
</span>
</li>
</ol>
</div>
<div :if={@grouping}>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-user-group" class="size-5 text-primary" /> グループ分けの結果
</h2>
<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>
<p :if={@grouping.ungrouped != []} class="text-sm text-base-content/60 mt-2">
未割り当て:{Enum.map_join(@grouping.ungrouped, "、", & &1.name)}
</p>
</div>
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-users" class="size-5" /> 参加者
<span class="badge badge-sm">{length(@participants)} 人</span>
</h2>
<div class="flex flex-wrap gap-2">
<span
:for={p <- @participants}
class={[
"badge badge-lg",
p.role == "owner" && "badge-primary",
p.role != "owner" && "badge-ghost"
]}
>
{p.name}
<span :if={p.role == "owner"} class="ml-1 text-xs">(オーナー)</span>
</span>
</div>
</div>
</Layouts.app>
"""
end
end
+367
View File
@@ -0,0 +1,367 @@
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