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
+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.