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
+2 -2
View File
@@ -12,8 +12,8 @@ defmodule Rsh26pool.Application do
Rsh26pool.Repo,
{DNSCluster, query: Application.get_env(:rsh26pool, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Rsh26pool.PubSub},
# Start a worker by calling: Rsh26pool.Worker.start_link(arg)
# {Rsh26pool.Worker, arg},
# Periodically closes rooms that have reached their auto-end time.
Rsh26pool.RoomCloser,
# Start to serve requests, typically the last entry
Rsh26poolWeb.Endpoint
]
+52
View File
@@ -0,0 +1,52 @@
defmodule Rsh26pool.JST do
@moduledoc """
Helpers for converting between UTC (how timestamps are stored) and
Japan Standard Time (JST, UTC+9) which is what the UI shows and accepts.
A full time zone database is not configured for this demo, and JST has no
daylight saving time, so a fixed +9h offset is sufficient and dependency free.
"""
@offset_seconds 9 * 3600
@doc """
Parses a `datetime-local` form value (e.g. `"2026-07-04T15:30"`), interpreting
it as wall-clock JST, and returns the corresponding UTC `DateTime`.
"""
@spec parse_local(String.t()) :: {:ok, DateTime.t()} | :error
def parse_local(value) when is_binary(value) do
with {:ok, naive} <- NaiveDateTime.from_iso8601(ensure_seconds(value)) do
utc =
naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.add(-@offset_seconds, :second)
|> DateTime.truncate(:second)
{:ok, utc}
else
_ -> :error
end
end
def parse_local(_), do: :error
@doc "Converts a stored UTC `DateTime` to a JST `DateTime` for display."
@spec to_local(DateTime.t()) :: DateTime.t()
def to_local(%DateTime{} = dt), do: DateTime.add(dt, @offset_seconds, :second)
@doc "Formats a stored UTC `DateTime` as a human friendly JST string."
@spec format(DateTime.t() | nil) :: String.t()
def format(nil), do: ""
def format(%DateTime{} = dt) do
l = to_local(dt)
"~4..0B/~2..0B/~2..0B ~2..0B:~2..0B"
|> :io_lib.format([l.year, l.month, l.day, l.hour, l.minute])
|> to_string()
end
defp ensure_seconds(value) do
if Regex.match?(~r/T\d{2}:\d{2}$/, value), do: value <> ":00", else: value
end
end
+45
View File
@@ -0,0 +1,45 @@
defmodule Rsh26pool.RoomCloser do
@moduledoc """
Periodically closes rooms whose `closes_at` deadline has passed (the "auto
end" feature). Closing broadcasts to any connected LiveViews so open pages
transition to the results/grouping view without a manual refresh.
"""
use GenServer
require Logger
alias Rsh26pool.Voting
@interval :timer.seconds(10)
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
schedule()
{:ok, %{}}
end
@impl true
def handle_info(:sweep, state) do
sweep()
schedule()
{:noreply, state}
end
defp sweep do
for room <- Voting.list_rooms_to_auto_close() do
case Voting.close_room(room) do
{:ok, _} ->
:ok
other ->
Logger.warning("RoomCloser failed to close room #{room.id}: #{inspect(other)}")
end
end
end
defp schedule, do: Process.send_after(self(), :sweep, @interval)
end
+523
View File
@@ -0,0 +1,523 @@
defmodule Rsh26pool.Voting do
@moduledoc """
The Voting context: rooms, participants, ideas/opinions, votes, results and
(in grouping mode) team assignment via the Hungarian algorithm.
Timestamps are stored in UTC. Real-time updates are published over
`Phoenix.PubSub`; interested LiveViews call `subscribe/1` and handle
`{:voting, event}` messages.
"""
import Ecto.Query, warn: false
alias Rsh26pool.Repo
alias Rsh26pool.Voting.{Hungarian, Idea, Participant, Room, Vote}
@pubsub Rsh26pool.PubSub
## Real-time -------------------------------------------------------------
@doc "Subscribe the calling process to a room's update stream."
def subscribe(room_id) do
Phoenix.PubSub.subscribe(@pubsub, topic(room_id))
end
defp broadcast(room_id, event) do
Phoenix.PubSub.broadcast(@pubsub, topic(room_id), {:voting, event})
end
defp topic(room_id), do: "room:#{room_id}"
## Rooms ----------------------------------------------------------------
@doc "Builds a creation changeset for the room settings form."
def change_room_creation(%Room{} = room \\ %Room{}, attrs \\ %{}) do
Room.create_changeset(room, attrs)
end
@doc "Builds a settings changeset for the manage page rename form."
def change_room_settings(%Room{} = room, attrs \\ %{}) do
Room.settings_changeset(room, attrs)
end
@doc """
Creates a room from the settings form and its owner participant.
Returns `{:ok, %{room: room, owner: owner}}` or `{:error, changeset}`.
"""
def create_room(attrs) do
changeset = Room.create_changeset(%Room{}, attrs)
if changeset.valid? do
changeset = Ecto.Changeset.put_change(changeset, :room_number, unique_room_number())
Repo.transaction(fn ->
room = Repo.insert!(changeset)
owner =
Repo.insert!(%Participant{
room_id: room.id,
role: "owner",
name: "オーナー",
token: generate_token()
})
%{room: room, owner: owner}
end)
else
{:error, %{changeset | action: :insert}}
end
end
def get_room_by_number(number) when is_binary(number) do
Repo.get_by(Room, room_number: number)
end
def get_room_by_number(_), do: nil
def get_room!(id), do: Repo.get!(Room, id)
def reload_room(%Room{id: id}), do: Repo.get(Room, id)
@doc "Updates owner-editable settings (currently the room name)."
def update_room_settings(%Room{} = room, attrs) do
room
|> Room.settings_changeset(attrs)
|> Repo.update()
|> case do
{:ok, room} = ok ->
broadcast(room.id, :room_updated)
ok
other ->
other
end
end
@doc """
The status a room should be treated as *right now*, accounting for a passed
`closes_at` deadline even if the background closer has not run yet.
"""
def effective_status(%Room{status: "closed"}), do: "closed"
def effective_status(%Room{closes_at: nil}), do: "open"
def effective_status(%Room{closes_at: closes_at}) do
if DateTime.compare(closes_at, DateTime.utc_now()) != :gt, do: "closed", else: "open"
end
def room_open?(%Room{} = room), do: effective_status(room) == "open"
@doc """
Closes a room: marks it closed and, in grouping mode, computes the team
assignments (idempotent — safe to call more than once).
"""
def close_room(%Room{} = room) do
room = Repo.get!(Room, room.id)
if room.status == "closed" do
{:ok, room}
else
{:ok, closed} =
room
|> Ecto.Changeset.change(status: "closed")
|> Repo.update()
closed = if closed.grouping_mode, do: compute_grouping(closed), else: closed
broadcast(closed.id, :room_updated)
{:ok, closed}
end
end
@doc """
Ensures a room past its deadline is actually closed. Returns the (possibly
updated) room. Used lazily by pages so viewers never see a stale open room.
"""
def ensure_closed(%Room{} = room) do
if room.status == "open" and effective_status(room) == "closed" do
{:ok, closed} = close_room(room)
closed
else
room
end
end
@doc "Lists rooms that are open but past their auto-close deadline."
def list_rooms_to_auto_close do
now = DateTime.utc_now()
Repo.all(
from r in Room,
where: r.status == "open" and not is_nil(r.closes_at) and r.closes_at <= ^now
)
end
## Participants ---------------------------------------------------------
@doc "Changeset for the join form."
def change_participant(%Participant{} = participant \\ %Participant{}, attrs \\ %{}) do
Participant.changeset(participant, attrs)
end
@doc """
Joins a room as a member, generating an auth token.
Returns `{:ok, participant}` or `{:error, changeset}`.
"""
def join_room(%Room{} = room, attrs) do
%Participant{room_id: room.id, role: "member", token: generate_token()}
|> Participant.changeset(attrs)
|> Repo.insert()
end
def get_participant_by_token(nil), do: nil
def get_participant_by_token(token) when is_binary(token) do
Repo.one(from p in Participant, where: p.token == ^token, preload: [:room])
end
def list_participants(%Room{} = room) do
Repo.all(from p in Participant, where: p.room_id == ^room.id, order_by: [asc: p.id])
end
def count_participants(%Room{} = room) do
Repo.aggregate(from(p in Participant, where: p.room_id == ^room.id), :count)
end
## Ideas ----------------------------------------------------------------
def change_idea(%Idea{} = idea \\ %Idea{}, attrs \\ %{}) do
Idea.changeset(idea, attrs)
end
@doc """
Posts an idea/opinion. In grouping mode each participant has a single opinion,
so an existing one is updated instead of inserting a second.
"""
def create_idea(%Room{} = room, %Participant{} = participant, attrs) do
cond do
not room_open?(room) ->
{:error, :closed}
room.grouping_mode ->
case get_participant_idea(room, participant) do
nil -> do_insert_idea(room, participant, attrs)
existing -> do_update_idea(room, existing, attrs)
end
true ->
do_insert_idea(room, participant, attrs)
end
end
defp do_insert_idea(room, participant, attrs) do
%Idea{room_id: room.id, participant_id: participant.id}
|> Idea.changeset(attrs)
|> Repo.insert()
|> tap_broadcast(room, :ideas_changed)
end
defp do_update_idea(room, idea, attrs) do
idea
|> Idea.changeset(attrs)
|> Repo.update()
|> tap_broadcast(room, :ideas_changed)
end
def get_participant_idea(%Room{} = room, %Participant{} = participant) do
Repo.one(
from i in Idea,
where: i.room_id == ^room.id and i.participant_id == ^participant.id,
order_by: [asc: i.id],
limit: 1
)
end
def list_ideas(%Room{} = room) do
Repo.all(
from i in Idea,
where: i.room_id == ^room.id,
order_by: [asc: i.id],
preload: [:participant]
)
end
@doc """
Ideas ordered by a per-viewer deterministic shuffle. Order is stable for a
given `seed` (so it does not jump around on live updates) but differs between
viewers, satisfying the "shuffled choices" requirement.
"""
def shuffled_ideas(%Room{} = room, seed) do
room
|> list_ideas()
|> Enum.sort_by(fn idea -> :erlang.phash2({seed, idea.id}) end)
end
## Votes ----------------------------------------------------------------
@doc """
Casts a vote for `idea_id` by `participant`, enforcing the room's per-user
vote limit, one-vote-per-idea, and that the room is still open.
"""
def vote(%Participant{} = participant, idea_id) do
room = Repo.get!(Room, participant.room_id)
idea = Repo.get_by(Idea, id: idea_id, room_id: room.id)
cond do
not room_open?(room) -> {:error, :closed}
is_nil(idea) -> {:error, :not_found}
voted?(participant, idea.id) -> {:error, :already_voted}
votes_used(participant) >= room.votes_per_user -> {:error, :limit_reached}
true -> insert_vote(room, participant, idea)
end
end
defp insert_vote(room, participant, idea) do
%Vote{}
|> Vote.changeset(%{idea_id: idea.id, participant_id: participant.id})
|> Repo.insert()
|> case do
{:ok, vote} ->
broadcast(room.id, :votes_changed)
{:ok, vote}
{:error, changeset} ->
{:error, changeset}
end
end
@doc "Removes a participant's vote for an idea, if the room is still open."
def unvote(%Participant{} = participant, idea_id) do
room = Repo.get!(Room, participant.room_id)
cond do
not room_open?(room) ->
{:error, :closed}
true ->
case Repo.get_by(Vote, idea_id: idea_id, participant_id: participant.id) do
nil ->
{:error, :not_found}
vote ->
{:ok, _} = Repo.delete(vote)
broadcast(room.id, :votes_changed)
:ok
end
end
end
def voted?(%Participant{} = participant, idea_id) do
Repo.exists?(
from v in Vote, where: v.participant_id == ^participant.id and v.idea_id == ^idea_id
)
end
def votes_used(%Participant{} = participant) do
Repo.aggregate(from(v in Vote, where: v.participant_id == ^participant.id), :count)
end
def votes_remaining(%Room{} = room, %Participant{} = participant) do
max(room.votes_per_user - votes_used(participant), 0)
end
@doc "Set of idea ids the participant has voted for (for UI highlighting)."
def voted_idea_ids(%Participant{} = participant) do
Repo.all(from v in Vote, where: v.participant_id == ^participant.id, select: v.idea_id)
|> MapSet.new()
end
## Results --------------------------------------------------------------
@doc "Map of `idea_id => vote_count` for a room."
def vote_counts(%Room{} = room) do
Repo.all(
from v in Vote,
join: i in Idea,
on: i.id == v.idea_id,
where: i.room_id == ^room.id,
group_by: v.idea_id,
select: {v.idea_id, count(v.id)}
)
|> Map.new()
end
@doc "Ideas with their vote counts, highest first (for the manage page)."
def results(%Room{} = room) do
counts = vote_counts(room)
room
|> list_ideas()
|> Enum.map(fn idea -> %{idea: idea, count: Map.get(counts, idea.id, 0)} end)
|> Enum.sort_by(& &1.count, :desc)
end
## Grouping mode --------------------------------------------------------
@doc """
Returns the computed grouping as `%{groups: [...], ungrouped: [...]}` where
each group is `%{leader: participant, opinion: idea, members: [participant]}`.
Only meaningful after a grouping-mode room has been closed.
"""
def group_assignments(%Room{} = room) do
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
counts = vote_counts(room)
idea_by_participant = idea_by_participant(room)
leaders =
members
|> Enum.filter(& &1.is_leader)
|> Enum.sort_by(&(-opinion_votes(&1, idea_by_participant, counts)))
groups =
Enum.map(leaders, fn leader ->
group_members =
Enum.filter(members, fn p ->
not p.is_leader and p.assigned_leader_id == leader.id
end)
%{
leader: leader,
opinion: Map.get(idea_by_participant, leader.id),
members: group_members
}
end)
ungrouped =
Enum.filter(members, fn p -> not p.is_leader and is_nil(p.assigned_leader_id) end)
%{groups: groups, ungrouped: ungrouped}
end
defp compute_grouping(room) do
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
counts = vote_counts(room)
idea_by_participant = idea_by_participant(room)
leaders =
Enum.filter(members, fn p ->
Map.has_key?(idea_by_participant, p.id) and
opinion_votes(p, idea_by_participant, counts) >= room.leader_threshold
end)
regular = members -- leaders
Repo.transaction(fn ->
if leaders == [] do
Enum.each(members, &set_group(&1, false, nil))
else
Enum.each(leaders, &set_group(&1, true, &1.id))
regular
|> assign_regular_members(leaders, room, idea_by_participant)
|> Enum.each(fn {member, leader_id} -> set_group(member, false, leader_id) end)
end
now = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.update!(Ecto.Changeset.change(room, grouped_at: now))
end)
|> case do
{:ok, updated_room} -> updated_room
end
end
# Assigns non-leader members to leader groups minimising unmet preferences via
# the Hungarian algorithm. Returns `[{member, leader_id}]`.
defp assign_regular_members([], _leaders, _room, _idea_by_participant), do: []
defp assign_regular_members(regular, leaders, room, idea_by_participant) do
m = length(regular)
l = length(leaders)
base = div(m, l)
remainder = rem(m, l)
# Balanced capacity per leader; expanded into one slot per capacity unit.
caps = Enum.map(0..(l - 1), fn i -> base + if(i < remainder, do: 1, else: 0) end)
slot_leaders =
Enum.flat_map(Enum.zip(leaders, caps), fn {leader, cap} ->
List.duplicate(leader, cap)
end)
voted_by = votes_by_participant(room)
cost =
Enum.map(regular, fn member ->
voted = Map.get(voted_by, member.id, MapSet.new())
Enum.map(slot_leaders, fn leader ->
opinion = Map.get(idea_by_participant, leader.id)
if opinion && MapSet.member?(voted, opinion.id), do: 0, else: 1
end)
end)
{_total, assignment} = Hungarian.min_cost_assignment(cost)
regular
|> Enum.zip(assignment)
|> Enum.map(fn {member, slot_index} ->
leader = Enum.at(slot_leaders, slot_index)
{member, leader.id}
end)
end
defp set_group(participant, is_leader, leader_id) do
participant
|> Ecto.Changeset.change(is_leader: is_leader, assigned_leader_id: leader_id)
|> Repo.update!()
end
defp idea_by_participant(room) do
room
|> list_ideas()
|> Map.new(fn idea -> {idea.participant_id, idea} end)
end
defp opinion_votes(participant, idea_by_participant, counts) do
case Map.get(idea_by_participant, participant.id) do
nil -> 0
idea -> Map.get(counts, idea.id, 0)
end
end
defp votes_by_participant(room) do
Repo.all(
from v in Vote,
join: i in Idea,
on: i.id == v.idea_id,
where: i.room_id == ^room.id,
select: {v.participant_id, v.idea_id}
)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Map.new(fn {participant_id, idea_ids} -> {participant_id, MapSet.new(idea_ids)} end)
end
## Helpers --------------------------------------------------------------
defp generate_token do
:crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false)
end
defp unique_room_number(attempts \\ 0)
defp unique_room_number(attempts) when attempts < 20 do
number = Integer.to_string(:rand.uniform(900_000) + 99_999)
if Repo.exists?(from r in Room, where: r.room_number == ^number) do
unique_room_number(attempts + 1)
else
number
end
end
defp unique_room_number(_attempts) do
raise "could not allocate a unique room number"
end
defp tap_broadcast({:ok, _} = result, room, event) do
broadcast(room.id, event)
result
end
defp tap_broadcast(other, _room, _event), do: other
end
+114
View File
@@ -0,0 +1,114 @@
defmodule Rsh26pool.Voting.Hungarian do
@moduledoc """
Solves the assignment problem — a minimum-cost perfect matching in a weighted
bipartite graph — with the Hungarian (KuhnMunkres) algorithm in O(n^3).
This is the algorithm the proposal calls for when placing members into groups
in grouping mode: given a cost of assigning each member to each group slot, it
finds the assignment with the lowest total cost.
`min_cost_assignment/1` takes an `n x m` cost matrix (a list of `n` rows, each
a list of `m` numbers) with `n <= m`, and returns `{total_cost, assignment}`
where `assignment` is a list of length `n` and `Enum.at(assignment, i)` is the
0-based column matched to row `i`.
"""
@inf 1_000_000_000
@spec min_cost_assignment([[number()]]) :: {number(), [non_neg_integer()]}
def min_cost_assignment([]), do: {0, []}
def min_cost_assignment(cost) when is_list(cost) do
n = length(cost)
m = length(hd(cost))
if n > m do
raise ArgumentError, "cost matrix must have rows <= cols (got #{n} x #{m})"
end
# 1-indexed access into an immutable tuple-of-tuples.
c = cost |> Enum.map(&List.to_tuple/1) |> List.to_tuple()
cost_at = fn i, j -> c |> elem(i - 1) |> elem(j - 1) end
u = fill(n + 1, 0)
v = fill(m + 1, 0)
p = fill(m + 1, 0)
way = fill(m + 1, 0)
{_u, _v, p, _way} =
Enum.reduce(1..n, {u, v, p, way}, fn i, {u, v, p, way} ->
phase(i, m, cost_at, u, v, p, way)
end)
# p[j] holds the row matched to column j; invert to get column-per-row.
row_to_col =
Enum.reduce(1..m, %{}, fn j, acc ->
row = elem(p, j)
if row >= 1 and row <= n, do: Map.put(acc, row, j - 1), else: acc
end)
assignment = Enum.map(1..n, &Map.fetch!(row_to_col, &1))
total =
assignment
|> Enum.with_index(1)
|> Enum.reduce(0, fn {col0, i}, sum -> sum + cost_at.(i, col0 + 1) end)
{total, assignment}
end
# Augmenting phase for row `i` (see the classic e-maxx Hungarian description).
defp phase(i, m, cost_at, u, v, p, way) do
p = put_elem(p, 0, i)
minv = fill(m + 1, @inf)
used = fill(m + 1, false)
walk(0, m, cost_at, u, v, p, way, minv, used)
end
defp walk(j0, m, cost_at, u, v, p, way, minv, used) do
used = put_elem(used, j0, true)
i0 = elem(p, j0)
ui0 = elem(u, i0)
{delta, j1, minv, way} =
Enum.reduce(1..m, {@inf, -1, minv, way}, fn j, {delta, j1, minv, way} ->
if elem(used, j) do
{delta, j1, minv, way}
else
cur = cost_at.(i0, j) - ui0 - elem(v, j)
{minv, way} =
if cur < elem(minv, j),
do: {put_elem(minv, j, cur), put_elem(way, j, j0)},
else: {minv, way}
mvj = elem(minv, j)
if mvj < delta, do: {mvj, j, minv, way}, else: {delta, j1, minv, way}
end
end)
{u, v, minv} =
Enum.reduce(0..m, {u, v, minv}, fn j, {u, v, minv} ->
if elem(used, j) do
pj = elem(p, j)
{put_elem(u, pj, elem(u, pj) + delta), put_elem(v, j, elem(v, j) - delta), minv}
else
{u, v, put_elem(minv, j, elem(minv, j) - delta)}
end
end)
if elem(p, j1) == 0 do
{u, v, reconstruct(j1, p, way), way}
else
walk(j1, m, cost_at, u, v, p, way, minv, used)
end
end
defp reconstruct(j0, p, way) do
j1 = elem(way, j0)
p = put_elem(p, j0, elem(p, j1))
if j1 == 0, do: p, else: reconstruct(j1, p, way)
end
defp fill(size, value), do: value |> List.duplicate(size) |> List.to_tuple()
end
+27
View File
@@ -0,0 +1,27 @@
defmodule Rsh26pool.Voting.Idea do
@moduledoc """
A voting option. In normal mode it is an idea posted by a participant; in
grouping mode it is the participant's opinion (one per participant).
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Participant, Room, Vote}
schema "ideas" do
field :text, :string
belongs_to :room, Room
belongs_to :participant, Participant
has_many :votes, Vote
timestamps(type: :utc_datetime)
end
def changeset(idea, attrs) do
idea
|> cast(attrs, [:text])
|> validate_required([:text], message: "内容を入力してください")
|> validate_length(:text, max: 200, message: "200文字以内で入力してください")
end
end
+37
View File
@@ -0,0 +1,37 @@
defmodule Rsh26pool.Voting.Participant do
@moduledoc """
A user taking part in a room. Identified across requests by a random `token`
stored in the session cookie. Owners have `role == "owner"`.
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.Room
schema "participants" do
field :token, :string
field :name, :string
field :role, :string, default: "member"
# Grouping-mode results, filled in when voting closes.
field :is_leader, :boolean, default: false
belongs_to :room, Room
belongs_to :assigned_leader, __MODULE__, foreign_key: :assigned_leader_id
timestamps(type: :utc_datetime)
end
@doc "Changeset for the user-supplied fields (their display name)."
def changeset(participant, attrs) do
participant
|> cast(attrs, [:name])
|> validate_required([:name], message: "名前を入力してください")
|> validate_length(:name, max: 40, message: "名前は40文字以内で入力してください")
|> unique_constraint(:token)
end
@doc "Owner? convenience predicate."
def owner?(%__MODULE__{role: "owner"}), do: true
def owner?(%__MODULE__{}), do: false
end
+81
View File
@@ -0,0 +1,81 @@
defmodule Rsh26pool.Voting.Room do
@moduledoc """
A voting room. Identified publicly by a short `room_number` used in URLs.
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Idea, Participant}
schema "rooms" do
field :room_number, :string
field :name, :string
field :votes_per_user, :integer, default: 3
field :status, :string, default: "open"
field :grouping_mode, :boolean, default: false
field :leader_threshold, :integer, default: 2
field :closes_at, :utc_datetime
field :grouped_at, :utc_datetime
# Virtual field backing the `datetime-local` input (wall-clock JST).
field :closes_at_local, :string, virtual: true
has_many :participants, Participant
has_many :ideas, Idea
timestamps(type: :utc_datetime)
end
@doc "Changeset used when the owner creates a new room."
def create_changeset(room, attrs) do
room
|> cast(attrs, [
:name,
:votes_per_user,
:grouping_mode,
:leader_threshold,
:closes_at_local
])
|> validate_required([:name, :votes_per_user], message: "入力してください")
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|> validate_number(:votes_per_user,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 50,
message: "1〜50の数値を入力してください"
)
|> validate_number(:leader_threshold,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 50,
message: "1〜50の数値を入力してください"
)
|> put_closes_at()
end
@doc "Changeset for owner-editable settings on the manage page."
def settings_changeset(room, attrs) do
room
|> cast(attrs, [:name])
|> validate_required([:name], message: "入力してください")
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
end
defp put_closes_at(changeset) do
case get_field(changeset, :closes_at_local) do
blank when blank in [nil, ""] ->
put_change(changeset, :closes_at, nil)
value ->
case Rsh26pool.JST.parse_local(value) do
{:ok, dt} ->
if DateTime.compare(dt, DateTime.utc_now()) == :gt do
put_change(changeset, :closes_at, dt)
else
add_error(changeset, :closes_at_local, "未来の日時を指定してください")
end
:error ->
add_error(changeset, :closes_at_local, "日時の形式が正しくありません")
end
end
end
end
+29
View File
@@ -0,0 +1,29 @@
defmodule Rsh26pool.Voting.Vote do
@moduledoc """
A single vote cast by a participant for an idea. A participant may vote for a
given idea at most once (enforced by a unique index).
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Idea, Participant}
schema "votes" do
belongs_to :idea, Idea
belongs_to :participant, Participant
timestamps(type: :utc_datetime)
end
def changeset(vote, attrs) do
vote
|> cast(attrs, [:idea_id, :participant_id])
|> validate_required([:idea_id, :participant_id])
|> unique_constraint([:idea_id, :participant_id],
name: :votes_idea_id_participant_id_index,
message: "は既に投票済みです"
)
|> foreign_key_constraint(:idea_id)
|> foreign_key_constraint(:participant_id)
end
end
+99
View File
@@ -0,0 +1,99 @@
defmodule Rsh26poolWeb.Auth do
@moduledoc """
Token-based authentication.
When a user creates or joins a room we generate a random token, persist it on
their `Participant` row, and store that token in the session cookie. On every
request the token is looked up to identify the current participant. This is
the token scheme described in the proposal (a random string whose owner is
recorded in the database, kept in a cookie).
"""
use Rsh26poolWeb, :verified_routes
import Plug.Conn
alias Phoenix.LiveView
alias Rsh26pool.Voting
@token_key "participant_token"
## Plug (controllers) ---------------------------------------------------
@doc "Assigns `:current_participant` from the session token (may be nil)."
def fetch_current_participant(conn, _opts) do
participant = participant_from_token(get_session(conn, @token_key))
assign(conn, :current_participant, participant)
end
@doc "Persists a participant's token in the session and assigns it."
def log_in_participant(conn, participant) do
conn
|> put_session(@token_key, participant.token)
|> assign(:current_participant, participant)
end
@doc "Clears the session token (logout)."
def log_out_participant(conn) do
conn
|> delete_session(@token_key)
|> assign(:current_participant, nil)
end
## LiveView on_mount hooks ---------------------------------------------
@doc """
`on_mount` hooks:
* `:mount_current_participant` — assigns `:current_participant` (may be nil)
* `:require_participant` — requires the viewer to belong to the room in the
URL, otherwise redirects to the enter page
* `:require_owner` — requires the viewer to be the owner of the room
"""
def on_mount(:mount_current_participant, _params, session, socket) do
{:cont, assign_current_participant(socket, session)}
end
def on_mount(:require_participant, params, session, socket) do
socket = assign_current_participant(socket, session)
participant = socket.assigns.current_participant
room_number = params["room_number"]
if participant && participant.room && participant.room.room_number == room_number do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "このルームに参加するには入室してください。")
|> LiveView.redirect(to: ~p"/enter?#{[room: room_number]}")
{:halt, socket}
end
end
def on_mount(:require_owner, params, session, socket) do
socket = assign_current_participant(socket, session)
participant = socket.assigns.current_participant
room_number = params["room_number"]
if participant && participant.room && participant.room.room_number == room_number &&
participant.role == "owner" do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "この管理ページにアクセスする権限がありません。")
|> LiveView.redirect(to: ~p"/")
{:halt, socket}
end
end
defp assign_current_participant(socket, session) do
Phoenix.Component.assign_new(socket, :current_participant, fn ->
participant_from_token(session[@token_key])
end)
end
defp participant_from_token(nil), do: nil
defp participant_from_token(token), do: Voting.get_participant_by_token(token)
end
+26 -32
View File
@@ -27,43 +27,39 @@ defmodule Rsh26poolWeb.Layouts do
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
attr :current_participant, :map,
default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
doc: "the currently logged-in participant, if any"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<header class="navbar bg-base-100 border-b border-base-200 px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<img src={~p"/images/logo.svg"} width="36" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
</a>
<.link navigate={~p"/"} class="flex items-center gap-2 text-lg font-bold">
<.icon name="hero-check-badge" class="size-7 text-primary" />
<span>みんなで投票</span>
</.link>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<.theme_toggle />
</li>
<li>
<a href="https://phoenix.hexdocs.pm/overview.html" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
<div class="flex-none flex items-center gap-3">
<.theme_toggle />
<div :if={@current_participant} class="flex items-center gap-2">
<span class="hidden sm:inline text-sm text-base-content/70">
{@current_participant.name} さん
</span>
<.form for={%{}} action={~p"/logout"} method="delete">
<button class="btn btn-sm btn-ghost gap-1" type="submit">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
<span class="hidden sm:inline">ログアウト</span>
</button>
</.form>
</div>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
<main class="px-4 py-10 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl space-y-6">
{render_slot(@inner_block)}
</div>
</main>
@@ -91,7 +87,7 @@ defmodule Rsh26poolWeb.Layouts do
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
title="インターネットに接続できません"
phx-disconnected={
show(".phx-client-error #client-error")
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
@@ -99,14 +95,13 @@ defmodule Rsh26poolWeb.Layouts do
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" />
再接続を試みています <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
title="問題が発生しました"
phx-disconnected={
show(".phx-server-error #server-error")
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
@@ -114,8 +109,7 @@ defmodule Rsh26poolWeb.Layouts do
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" />
再接続を試みています <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
@@ -1,10 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Rsh26pool" suffix=" · Phoenix Framework" phx-no-format>{assigns[:page_title]}</.live_title>
<.live_title default="みんなで投票" suffix=" · 投票アプリ" phx-no-format>{assigns[:page_title]}</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
@@ -1,199 +1,67 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1>
<Layouts.theme_toggle />
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
<Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="text-center py-6">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight">
みんなで<span class="text-primary">投票</span>
</h1>
<p class="mt-4 text-base-content/70">
ルームを作って、アイデアを出し合い、好きなものに投票しよう。
</p>
<p class="mt-4 leading-7 text-base-content/70">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://phoenix.hexdocs.pm/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div
:if={@current_participant}
class="alert bg-base-200 border border-base-300 flex items-center justify-between"
>
<span>
現在ルーム「{@current_participant.room.name}」(番号 {@current_participant.room.room_number})に参加中です。
</span>
<.link
:if={@current_participant.role == "owner"}
navigate={~p"/manage/#{@current_participant.room.room_number}"}
class="btn btn-sm btn-primary"
>
管理ページへ
</.link>
<.link
:if={@current_participant.role != "owner"}
navigate={~p"/vote/#{@current_participant.room.room_number}"}
class="btn btn-sm btn-primary"
>
投票ページへ
</.link>
</div>
<div class="grid gap-4 sm:grid-cols-2 mt-4">
<.link
navigate={~p"/create"}
id="home-create-link"
class="card bg-base-100 border border-base-200 shadow-sm hover:shadow-md hover:border-primary transition group"
>
<div class="card-body items-center text-center">
<div class="rounded-full bg-primary/10 p-4 group-hover:bg-primary/20 transition">
<.icon name="hero-plus-circle" class="size-8 text-primary" />
</div>
<h2 class="card-title mt-2">ルームを作成</h2>
<p class="text-sm text-base-content/70">
投票の設定を決めて、新しいルームを立ち上げます。
</p>
</div>
</.link>
<.link
navigate={~p"/enter"}
id="home-enter-link"
class="card bg-base-100 border border-base-200 shadow-sm hover:shadow-md hover:border-primary transition group"
>
<div class="card-body items-center text-center">
<div class="rounded-full bg-primary/10 p-4 group-hover:bg-primary/20 transition">
<.icon name="hero-arrow-right-on-rectangle" class="size-8 text-primary" />
</div>
<h2 class="card-title mt-2">ルームに参加</h2>
<p class="text-sm text-base-content/70">
ルーム番号を入力して、既存のルームに参加します。
</p>
</div>
</.link>
</div>
</Layouts.app>
@@ -0,0 +1,89 @@
defmodule Rsh26poolWeb.RoomController do
use Rsh26poolWeb, :controller
import Phoenix.Component, only: [to_form: 1, to_form: 2]
alias Rsh26pool.Voting
alias Rsh26poolWeb.Auth
def new(conn, _params) do
render(conn, :new,
form: to_form(Voting.change_room_creation()),
page_title: "ルームを作成"
)
end
def create(conn, %{"room" => room_params}) do
case Voting.create_room(room_params) do
{:ok, %{room: room, owner: owner}} ->
conn
|> Auth.log_in_participant(owner)
|> put_flash(
:info,
"ルーム「#{room.name}」を作成しました。ルーム番号は #{room.room_number} です。"
)
|> redirect(to: ~p"/manage/#{room.room_number}")
{:error, changeset} ->
conn
|> put_flash(:error, "ルームを作成できませんでした。入力内容を確認してください。")
|> render(:new, form: to_form(changeset), page_title: "ルームを作成")
end
end
def enter_form(conn, params) do
render(conn, :enter,
form: enter_form_data(params["room"] || "", ""),
page_title: "ルームに参加"
)
end
def enter(conn, %{"enter" => %{"room_number" => number, "name" => name}}) do
number = String.trim(number || "")
name = String.trim(name || "")
cond do
number == "" or name == "" ->
conn
|> put_flash(:error, "ルーム番号と名前を入力してください。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
true ->
enter_room(conn, number, name)
end
end
def logout(conn, _params) do
conn
|> Auth.log_out_participant()
|> put_flash(:info, "ログアウトしました。")
|> redirect(to: ~p"/")
end
defp enter_room(conn, number, name) do
case Voting.get_room_by_number(number) do
nil ->
conn
|> put_flash(:error, "ルーム番号「#{number}」のルームが見つかりません。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
room ->
case Voting.join_room(room, %{"name" => name}) do
{:ok, participant} ->
conn
|> Auth.log_in_participant(participant)
|> put_flash(:info, "ルーム「#{room.name}」に参加しました。")
|> redirect(to: ~p"/vote/#{room.room_number}")
{:error, _changeset} ->
conn
|> put_flash(:error, "参加できませんでした。名前は40文字以内で入力してください。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
end
end
end
defp enter_form_data(number, name) do
to_form(%{"room_number" => number, "name" => name}, as: :enter)
end
end
@@ -0,0 +1,8 @@
defmodule Rsh26poolWeb.RoomHTML do
@moduledoc """
Templates rendered by `RoomController` (room creation and joining).
"""
use Rsh26poolWeb, :html
embed_templates "room_html/*"
end
@@ -0,0 +1,40 @@
<Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="mx-auto max-w-md">
<.header>
ルームに参加
<:subtitle>ルーム番号とあなたの名前を入力してください。</:subtitle>
</.header>
<div class="card bg-base-100 border border-base-200 shadow-sm mt-4">
<div class="card-body">
<.form for={@form} action={~p"/enter"} method="post" id="enter-room-form">
<.input
field={@form[:room_number]}
type="text"
label="ルーム番号"
placeholder="6桁の番号"
inputmode="numeric"
required
/>
<.input
field={@form[:name]}
type="text"
label="あなたの名前"
placeholder="例:たろう"
required
/>
<div class="flex items-center justify-between gap-3 mt-2">
<.link navigate={~p"/"} class="btn btn-ghost btn-sm">
戻る
</.link>
<button type="submit" class="btn btn-primary">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> 参加する
</button>
</div>
</.form>
</div>
</div>
</div>
</Layouts.app>
@@ -0,0 +1,65 @@
<Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="mx-auto max-w-xl">
<.header>
ルームを作成
<:subtitle>投票の設定を入力してください。</:subtitle>
</.header>
<div class="card bg-base-100 border border-base-200 shadow-sm mt-4">
<div class="card-body">
<.form for={@form} action={~p"/create"} method="post" id="create-room-form">
<.input
field={@form[:name]}
type="text"
label="ルーム名"
placeholder="例:今日のランチ投票"
required
/>
<.input
field={@form[:votes_per_user]}
type="number"
label="一人あたりの投票数"
min="1"
max="50"
required
/>
<div class="divider text-sm text-base-content/60">追加オプション</div>
<.input
field={@form[:grouping_mode]}
type="checkbox"
label="グルーピングモードを有効にする(班分けアプリになります)"
/>
<.input
field={@form[:leader_threshold]}
type="number"
label="リーダーになるのに必要な票数(グルーピングモードのときのみ使用)"
min="1"
max="50"
/>
<.input
field={@form[:closes_at_local]}
type="datetime-local"
label="投票終了日時(任意・日本時間)"
/>
<p class="text-xs text-base-content/60 -mt-1 mb-3">
指定すると、その日時に自動的に投票が終了します。
</p>
<div class="flex items-center justify-between gap-3 mt-2">
<.link navigate={~p"/"} class="btn btn-ghost btn-sm">
戻る
</.link>
<button type="submit" class="btn btn-primary">
<.icon name="hero-plus" class="size-4" /> ルームを作成
</button>
</div>
</.form>
</div>
</div>
</div>
</Layouts.app>
+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
+19
View File
@@ -1,6 +1,8 @@
defmodule Rsh26poolWeb.Router do
use Rsh26poolWeb, :router
import Rsh26poolWeb.Auth, only: [fetch_current_participant: 2]
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -8,6 +10,7 @@ defmodule Rsh26poolWeb.Router do
plug :put_root_layout, html: {Rsh26poolWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_participant
end
pipeline :api do
@@ -18,6 +21,22 @@ defmodule Rsh26poolWeb.Router do
pipe_through :browser
get "/", PageController, :home
get "/create", RoomController, :new
post "/create", RoomController, :create
get "/enter", RoomController, :enter_form
post "/enter", RoomController, :enter
delete "/logout", RoomController, :logout
live_session :voter,
on_mount: [{Rsh26poolWeb.Auth, :require_participant}] do
live "/vote/:room_number", VoteLive
end
live_session :owner,
on_mount: [{Rsh26poolWeb.Auth, :require_owner}] do
live "/manage/:room_number", ManageLive
end
end
# Other scopes may use custom stacks.