refactored for new project name

This commit is contained in:
Adam Piontek 2021-03-05 19:23:32 -05:00
commit 82ab1d1ea5
113 changed files with 417 additions and 412 deletions

View file

@ -0,0 +1,18 @@
defmodule Shift73kWeb.AdminDashboardLive do
use Shift73kWeb, :live_view
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(socket, session)
{:ok, socket}
end
@impl true
def render(assigns) do
~L"""
<section class="phx-hero">
<h1>Welcome to the admin dashboard!</h1>
</section>
"""
end
end

View file

@ -0,0 +1,69 @@
defmodule Shift73kWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
alias Shift73k.Accounts
alias Shift73k.Accounts.User
alias Shift73kWeb.UserAuth
@doc """
Performs the {:noreply, socket} for a given socket.
This helps make the noreply pipeable
"""
def live_noreply(socket), do: {:noreply, socket}
@doc """
Performs the {:ok, socket} for a given socket.
This helps make the ok reply pipeable
"""
def live_okreply(socket), do: {:ok, socket}
@doc """
Renders a component inside the `Shift73kWeb.ModalComponent` component.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
action: @live_action,
property: @property,
return_to: Routes.property_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
modal_opts = [id: :modal, component: component, opts: opts]
# dirty little workaround for elixir complaining about socket being unused
_socket = socket
live_component(socket, Shift73kWeb.ModalComponent, modal_opts)
end
@doc """
Loads default assigns for liveviews
"""
def assign_defaults(socket, session) do
Shift73kWeb.Endpoint.subscribe(UserAuth.pubsub_topic())
assign_current_user(socket, session)
end
# For liveviews, ensures current_user is in socket assigns.
def assign_current_user(socket, session) do
with user_token when not is_nil(user_token) <- session["user_token"],
%User{} = user <- Accounts.get_user_by_session_token(user_token) do
assign(socket, :current_user, user)
else
_ -> socket
end
end
@doc """
Copies current flash into new put_flash invocations.
To be used before a push_patch.
"""
def copy_flash(%{assigns: %{flash: flash}} = socket) do
Enum.reduce(flash, socket, fn {k, v}, acc ->
put_flash(acc, String.to_existing_atom(k), v)
end)
end
end

View file

@ -0,0 +1,40 @@
defmodule Shift73kWeb.ModalComponent do
use Shift73kWeb, :live_component
@impl true
def render(assigns) do
~L"""
<div id="<%= @id %>" class="modal fade"
phx-hook="BsModal"
phx-window-keydown="hide"
phx-key="escape"
phx-target="#<%= @id %>"
phx-page-loading>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<%= live_component @socket, @component, @opts %>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("close", _, socket) do
send(self(), {:close_modal, true})
live_noreply(socket)
end
@impl true
def handle_event("hide", _, socket) do
{:noreply, push_event(socket, "modal-please-hide", %{})}
end
end

View file

@ -0,0 +1,40 @@
defmodule Shift73kWeb.PageLive do
use Shift73kWeb, :live_view
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(socket, session)
{:ok, assign(socket, query: "", results: %{})}
end
@impl true
def handle_event("suggest", %{"q" => query}, socket) do
{:noreply, assign(socket, results: search(query), query: query)}
end
@impl true
def handle_event("search", %{"q" => query}, socket) do
case search(query) do
%{^query => vsn} ->
{:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")}
_ ->
{:noreply,
socket
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|> assign(results: %{}, query: query)}
end
end
defp search(query) do
if not Shift73kWeb.Endpoint.config(:code_reloader) do
raise "action disabled when not in development"
end
for {app, desc, vsn} <- Application.started_applications(),
app = to_string(app),
String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"),
into: %{},
do: {app, vsn}
end
end

View file

@ -0,0 +1,49 @@
<section class="phx-hero">
<h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
<p>Peace of mind from prototype to production</p>
<form phx-change="suggest" phx-submit="search">
<input type="text" name="q" value="<%= @query %>" placeholder="Live dependency search" list="results" autocomplete="off"/>
<datalist id="results">
<%= for {app, _vsn} <- @results do %>
<option value="<%= app %>"><%= app %></option>
<% end %>
</datalist>
<button type="submit" phx-disable-with="Searching...">Go to Hexdocs</button>
</form>
</section>
<section class="row align-items-start">
<article class="col">
<h2>Resources</h2>
<ul>
<li>
<a href="https://hexdocs.pm/phoenix/overview.html">Guides &amp; Docs</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix">Source</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix/blob/v1.5/CHANGELOG.md">v1.5 Changelog</a>
</li>
</ul>
</article>
<article class="col">
<h2>Help</h2>
<ul>
<li>
<a href="https://elixirforum.com/c/phoenix-forum">Forum</a>
</li>
<li>
<a href="https://webchat.freenode.net/?channels=elixir-lang">#elixir-lang on Freenode IRC</a>
</li>
<li>
<a href="https://twitter.com/elixirphoenix">Twitter @elixirphoenix</a>
</li>
<li>
<a href="https://elixir-slackin.herokuapp.com/">Elixir on Slack</a>
</li>
</ul>
</article>
</section>

View file

@ -0,0 +1,66 @@
defmodule Shift73kWeb.PropertyLive.FormComponent do
use Shift73kWeb, :live_component
alias Shift73k.Properties
@impl true
def update(%{property: property} = assigns, socket) do
socket
|> assign(assigns)
|> assign(:changeset, Properties.change_property(property))
|> live_okreply()
end
@impl true
def handle_event("validate", %{"property" => property_params}, socket) do
changeset =
socket.assigns.property
|> Properties.change_property(property_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
@impl true
def handle_event("save", %{"property" => property_params}, socket) do
save_property(socket, socket.assigns.action, property_params)
end
@impl true
def handle_event("cancel", _, socket) do
{:noreply, push_event(socket, "modal-please-hide", %{})}
end
defp save_property(socket, :edit, property_params) do
case Properties.update_property(socket.assigns.property, property_params) do
{:ok, _property} ->
flash = {:info, "Property updated successfully"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_property(socket, :new, property_params) do
current_user = socket.assigns.current_user
property_params = Map.put(property_params, "user_id", current_user.id)
case Properties.create_property(property_params) do
{:ok, _property} ->
flash = {:info, "Property created successfully"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end

View file

@ -0,0 +1,57 @@
<%= form_for @changeset, "#", [
id: "property-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<div class="mb-3" phx-feedback-for="<%= input_id(f, :name)%>">
<%= label f, :name, class: "form-label" %>
<div class="input-group has-validation">
<%= text_input f, :name,
class: input_class(f, :name, "form-control"),
aria_describedby: error_id(f, :name)
%>
<%= error_tag f, :name %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :price)%>">
<%= label f, :price, class: "form-label" %>
<div class="input-group has-validation">
<%= number_input f, :price,
class: input_class(f, :price, "form-control"),
step: "any",
aria_describedby: error_id(f, :price)
%>
<%= error_tag f, :price %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :description)%>">
<%= label f, :description, class: "form-label" %>
<div class="input-group has-validation">
<%= textarea f, :description,
class: input_class(f, :description, "form-control"),
aria_describedby: error_id(f, :description)
%>
<%= error_tag f, :description %>
</div>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-secondary me-2", phx_click: "cancel", phx_target: @myself %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -0,0 +1,90 @@
defmodule Shift73kWeb.PropertyLive.Index do
use Shift73kWeb, :live_view
alias Shift73k.Properties
alias Shift73k.Properties.Property
alias Shift73kWeb.Roles
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(socket, session)
{:ok, assign(socket, :properties, [])}
end
@impl true
def handle_params(params, _url, socket) do
current_user = socket.assigns.current_user
live_action = socket.assigns.live_action
property = property_from_params(params)
if Roles.can?(current_user, property, live_action) do
socket
|> assign(:properties, list_properties())
|> assign(:modal_return_to, Routes.property_index_path(socket, :index))
|> apply_action(live_action, params)
|> live_noreply()
else
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")
|> live_noreply()
end
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Property")
|> assign(:property, Properties.get_property!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Property")
|> assign(:property, %Property{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Properties")
|> assign(:property, nil)
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
current_user = socket.assigns.current_user
property = Properties.get_property!(id)
if Shift73kWeb.Roles.can?(current_user, property, :delete) do
property = Properties.get_property!(id)
{:ok, _} = Properties.delete_property(property)
{:noreply, assign(socket, :properties, list_properties())}
else
{:noreply,
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")}
end
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_return_to: to}} = socket) do
socket |> copy_flash() |> push_patch(to: to) |> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp property_from_params(params)
defp property_from_params(%{"id" => id}),
do: Properties.get_property!(id)
defp property_from_params(_params), do: %Property{}
defp list_properties do
Properties.list_properties()
end
end

View file

@ -0,0 +1,46 @@
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
title: @page_title,
action: @live_action,
property: @property,
current_user: @current_user %>
<% end %>
<div class="d-flex justify-content-between d-flex align-items-end">
<h2>Listing Properties</h2>
<span><%= live_patch "New Property", to: Routes.property_index_path(@socket, :new), class: "btn btn-primary" %></span>
</div>
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Price</th>
<th scope="col">Description</th>
<th></th>
</tr>
</thead>
<tbody id="properties">
<%= for property <- @properties do %>
<tr id="property-<%= property.id %>">
<td><%= property.name %></td>
<td><%= property.price %></td>
<td><%= property.description %></td>
<td>
<%= if Roles.can?(@current_user, property, :show) do %>
<%= live_redirect "Show", to: Routes.property_show_path(@socket, :show, property), class: "link-secondary mx-1" %>
<% end %>
<%= if Roles.can?(@current_user, property, :edit) do %>
<%= live_patch "Edit", to: Routes.property_index_path(@socket, :edit, property), class: "mx-1" %>
<% end %>
<%= if Roles.can?(@current_user, property, :delete) do %>
<%= link "Delete", to: "#", phx_click: "delete", phx_value_id: property.id, data: [confirm: "Are you sure?"], class: "link-danger mx-1" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,45 @@
defmodule Shift73kWeb.PropertyLive.Show do
use Shift73kWeb, :live_view
alias Shift73k.Properties
alias Shift73kWeb.Roles
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(socket, session)
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
current_user = socket.assigns.current_user
live_action = socket.assigns.live_action
property = Properties.get_property!(id)
if Roles.can?(current_user, property, live_action) do
socket
|> assign(:property, property)
|> assign(:page_title, page_title(live_action))
|> assign(:modal_return_to, Routes.property_show_path(socket, :show, property))
|> live_noreply()
else
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")
|> live_noreply()
end
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_return_to: to}} = socket) do
socket |> copy_flash() |> push_patch(to: to) |> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp page_title(:show), do: "Show Property"
defp page_title(:edit), do: "Edit Property"
end

View file

@ -0,0 +1,33 @@
<h2>Show Property</h2>
<%= if @live_action in [:edit] do %>
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id,
title: @page_title,
action: @live_action,
property: @property %>
<% end %>
<table class="table table-nonfluid">
<tbody>
<tr>
<th scope="row" class="text-end">Name</th>
<td><%= @property.name %></td>
</tr>
<tr>
<th scope="row" class="text-end">Price</th>
<td><%= @property.price %></td>
</tr>
<tr>
<th scope="row" class="text-end">Description</th>
<td><%= @property.description %></td>
</tr>
</tbody>
</table>
<%= if Roles.can?(@current_user, @property, :index) do %>
<%= live_redirect "Back", to: Routes.property_index_path(@socket, :index), class: "btn btn-secondary" %>
<% end %>
<%= if Roles.can?(@current_user, @property, :edit) do %>
<%= live_patch "Edit", to: Routes.property_show_path(@socket, :edit, @property), class: "btn btn-primary" %>
<% end %>

View file

@ -0,0 +1,61 @@
defmodule Shift73kWeb.UserLive.Registration do
use Shift73kWeb, :live_view
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@impl true
def mount(_params, session, socket) do
socket
|> assign_defaults(session)
|> assign(page_title: "Register")
|> assign(changeset: Accounts.change_user_registration(%User{}))
|> assign(login_params: init_login_params(session))
|> assign(trigger_submit: false)
|> live_okreply()
end
defp init_login_params(session) do
%{
user_id: nil,
user_return_to: Map.get(session, "user_return_to", "/"),
messages: [
success: "Welcome! Your new account has been created, and you've been logged in.",
info:
"Some features may be unavailable until you confirm your email address. Check your inbox for instructions."
]
}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
user_params
|> Map.put("role", Accounts.registration_role())
|> Accounts.register_user()
|> case do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
socket
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|> assign(trigger_submit: true)
|> live_noreply()
{:error, cs} ->
socket
|> put_flash(:error, "Ope &mdash; registration failed for some reason.")
|> assign(:changeset, cs)
|> live_noreply()
end
end
end

View file

@ -0,0 +1,70 @@
<div class="row justify-content-center">
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
<h2>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline"] %>
Register
</h2>
<p class="lead">Registration gains additional features, like remembering your song request history.</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "reg_form"], fn f -> %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "blur",
aria_describedby: error_id(f, :email)
%>
<%= error_tag f, :email %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= label f, :password, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
phx_debounce: "250",
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
</div>
</div>
<div class="mb-3">
<%= submit (@trigger_submit && "Saving..." || "Register"),
class: "btn btn-primary",
disabled: @trigger_submit || !@changeset.valid?,
aria_disabled: (@trigger_submit || !@changeset.valid?) && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
<p>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> |
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@socket, :new) %>
</p>
<%# hidden form for initial login after registration %>
<%= form_for :user, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit, id: "reg_trigger"], fn f -> %>
<%= hidden_input f, :params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,40 @@
defmodule Shift73kWeb.UserLive.ResetPassword do
use Shift73kWeb, :live_view
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@impl true
def mount(_params, session, socket) do
user = Accounts.get_user!(session["user_id"])
socket
|> assign_defaults(session)
|> assign(page_title: "Reset password")
|> assign(changeset: Accounts.change_user_password(user))
|> assign(user: user)
|> live_okreply()
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = Accounts.change_user_password(socket.assigns.user, user_params)
{:noreply, socket |> assign(changeset: %{cs | action: :validate})}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: Routes.user_session_path(socket, :new))}
{:error, changeset} ->
{:noreply,
socket
|> put_flash(:error, "Please check the errors below.")
|> assign(changeset: changeset)}
end
end
end

View file

@ -0,0 +1,62 @@
<div class="row justify-content-center">
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
<h2>
<%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %>
Reset password
</h2>
<p class="lead">Hi <%= @user.email %> &mdash; What new word of passage will confirm you are who you say you are?</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= label f, :password, "New password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
autofocus: true,
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key-fill", [class: "icon"] %>
</span>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
maxlength: User.max_password,
aria_describedby: error_id(f, :password_confirmation)
%>
<%= error_tag f, :password_confirmation %>
</div>
</div>
<div class="mb-3">
<%= submit "Reset password",
class: "btn btn-primary",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
<p class="mt-3 is-pulled-right">
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %>
</p>
</div>
</div>

View file

@ -0,0 +1,39 @@
defmodule Shift73kWeb.UserLive.Settings do
use Shift73kWeb, :live_view
alias Shift73k.Accounts.User
@impl true
def mount(_params, session, socket) do
socket
|> assign_defaults(session)
|> alert_email_verified?()
|> live_okreply()
end
defp alert_email_verified?(socket) do
case socket.assigns.current_user do
%{confirmed_at: nil} ->
put_flash(socket, :warning, [
"Your email hasn't been confirmed, some areas may be restricted. Shall we ",
link("resend the verification email?",
to: Routes.user_confirmation_path(socket, :new),
class: "alert-link"
)
])
_ ->
socket
end
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
@impl true
def handle_info({:clear_flash_message, flash_type}, socket) do
socket |> clear_flash(flash_type) |> live_noreply()
end
end

View file

@ -0,0 +1,9 @@
<h2 class="mb-3">
<%= icon_div @socket, "bi-sliders", [class: "icon baseline"] %>
User Settings
</h2>
<div class="row">
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Email, id: "email-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Password, id: "password-#{@current_user.id}", current_user: @current_user %>
</div>

View file

@ -0,0 +1,62 @@
defmodule Shift73kWeb.UserLive.Settings.Email do
use Shift73kWeb, :live_component
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@impl true
def update(%{current_user: user} = assigns, socket) do
socket
|> assign(id: assigns.id)
|> assign(current_user: user)
|> assign(changeset: get_changeset(user))
|> live_okreply()
end
defp get_changeset(user, user_params \\ %{}) do
Accounts.change_user_email(user, user_params)
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = get_changeset(socket.assigns.current_user, user_params)
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
end
# user_settings_path GET /users/settings/confirm_email/:token Shift73kWeb.UserSettingsController :confirm_email
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.apply_user_email(socket.assigns.current_user, user_params) do
{:ok, applied_user} ->
Accounts.deliver_update_email_instructions(
applied_user,
socket.assigns.current_user.email,
&Routes.user_settings_url(socket, :confirm_email, &1)
)
send(self(), {:clear_flash_message, :error})
send(
self(),
{:put_flash_message,
{:info, "A link to confirm your e-mail change has been sent to the new address."}}
)
socket
|> assign(changeset: get_changeset(socket.assigns.current_user))
|> live_noreply()
{:error, cs} ->
cu = socket.assigns.current_user
cpw = user_params["current_password"]
valid_password? = User.valid_password?(cu, cpw)
msg = (valid_password? && "Could not reset email.") || "Invalid current password."
send(self(), {:put_flash_message, {:error, msg}})
socket
|> assign(changeset: cs)
|> live_noreply()
end
end
end

View file

@ -0,0 +1,52 @@
<div id="<%= @id %>" class="col-sm-9 col-md-7 col-lg-5 col-xl-4 mt-1">
<h3>Change email</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
phx_debounce: "500",
aria_describedby: error_id(f, :email)
%>
<%= error_tag f, :email %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :current_password) %>">
<%= label f, :current_password, class: "form-label" %>
<div class="input-group">
<span class="input-group-text">
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
</span>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
class: "form-control",
aria_describedby: error_id(f, :current_password)
%>
<%= error_tag f, :current_password %>
</div>
</div>
<div class="mb-3">
<%= submit "Change email",
class: "btn btn-primary",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
aria_disabled: (!@changeset.valid? || input_value(f, :current_password) == "") && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
</div>

View file

@ -0,0 +1,57 @@
defmodule Shift73kWeb.UserLive.Settings.Password do
use Shift73kWeb, :live_component
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@impl true
def update(%{current_user: user} = assigns, socket) do
socket
|> assign(id: assigns.id)
|> assign(current_user: user)
|> assign(changeset: get_changeset(user))
|> assign(login_params: init_login_params(socket))
|> assign(trigger_submit: false)
|> live_okreply()
end
defp get_changeset(user, user_params \\ %{}) do
Accounts.change_user_password(user, user_params)
end
defp init_login_params(socket) do
%{
user_id: nil,
user_return_to: Routes.user_settings_path(socket, :edit),
messages: [info: "Password updated successfully."]
}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = get_changeset(socket.assigns.current_user, user_params)
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.update_user_password(socket.assigns.current_user, user_params) do
{:ok, user} ->
socket
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|> assign(trigger_submit: true)
|> live_noreply()
{:error, cs} ->
cu = socket.assigns.current_user
cpw = user_params["current_password"]
valid_password? = User.valid_password?(cu, cpw)
msg = (valid_password? && "Could not change password.") || "Invalid current password."
send(self(), {:put_flash_message, {:error, msg}})
socket
|> assign(changeset: cs)
|> live_noreply()
end
end
end

View file

@ -0,0 +1,77 @@
<div id="<%= @id %>" class="col-sm-9 col-md-7 col-lg-5 col-xl-4 mt-1">
<h3>Change password</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= label f, :password, "New password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
phx_debounce: "500",
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key-fill", [class: "icon"] %>
</span>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
maxlength: User.max_password,
aria_describedby: error_id(f, :password_confirmation)
%>
<%= error_tag f, :password_confirmation %>
</div>
</div>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :current_password) %>">
<%= label f, :current_password, class: "form-label" %>
<div class="input-group">
<span class="input-group-text">
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
</span>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
class: "form-control",
aria_describedby: error_id(f, :current_password)
%>
<%= error_tag f, :current_password %>
</div>
</div>
<div class="mb-3">
<%= submit "Change password",
class: "btn btn-primary",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
aria_disabled: (!@changeset.valid? || input_value(f, :current_password) == "") && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
<%# hidden form for initial login after registration %>
<%= form_for :user, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit, id: "settings_pw_change_trigger"], fn f -> %>
<%= hidden_input f, :params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
<% end %>
<%# hidden form to submit user for relogin after password change %>
<%#= form_for :user_login, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit], fn f -> %>
<%#= hidden_input f, :login_params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
<%#= hidden_input f, :remember_me, value: false %>
<%# end %>
</div>

View file

@ -0,0 +1,18 @@
defmodule Shift73kWeb.UserDashboardLive do
use Shift73kWeb, :live_view
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(socket, session)
{:ok, socket}
end
@impl true
def render(assigns) do
~L"""
<section class="phx-hero">
<h2>Welcome to the user dashboard!</h2>
</section>
"""
end
end

View file

@ -0,0 +1,44 @@
defmodule Shift73kWeb.UserManagement.DeleteComponent do
use Shift73kWeb, :live_component
alias Shift73k.Accounts
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> live_okreply()
end
@impl true
def handle_event("cancel", _, socket) do
{:noreply, push_event(socket, "modal-please-hide", %{})}
end
@impl true
def handle_event("confirm", %{"id" => id, "email" => email}, socket) do
id
|> Accounts.get_user()
|> Accounts.delete_user()
|> case do
{:ok, _} ->
flash = {:info, "User deleted successfully: \"#{email}\""}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, _} ->
flash =
{:error,
"Some error trying to delete user \"#{email}\". Possibly already deleted? Reloading list..."}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
end
end
end

View file

@ -0,0 +1,16 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-secondary me-2", phx_click: "cancel", phx_target: @myself %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>

View file

@ -0,0 +1,95 @@
defmodule Shift73kWeb.UserManagement.FormComponent do
use Shift73kWeb, :live_component
alias Shift73k.Accounts
alias Shift73k.Accounts.User
alias Shift73kWeb.Roles
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> init_changeset(assigns)
|> live_okreply()
end
defp init_changeset(socket, %{action: :new, user: user}) do
params = %{role: Accounts.registration_role()}
assign(socket, changeset: Accounts.change_user_registration(user, params))
end
defp init_changeset(socket, %{action: :edit, user: user}) do
assign(socket, changeset: Accounts.change_user_update(user))
end
defp validate_changes(%{action: :new, user: user}, user_params) do
Accounts.change_user_registration(user, user_params)
end
defp validate_changes(%{action: :edit, user: user}, user_params) do
Accounts.change_user_update(user, user_params)
end
defp save_user(%{assigns: %{action: :new}} = socket, user_params) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
flash = {:info, "User created successfully: #{user.email}"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, cs} ->
socket
|> put_flash(:error, "Some error creating this user...")
|> assign(changeset: cs)
|> live_noreply()
end
end
defp save_user(%{assigns: %{action: :edit, user: user}} = socket, user_params) do
case Accounts.update_user(user, user_params) do
{:ok, user} ->
flash = {:info, "User updated successfully: #{user.email}"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, cs} ->
{:noreply, assign(socket, :changeset, cs)}
end
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = validate_changes(socket.assigns, user_params)
{:noreply, assign(socket, :changeset, %{cs | action: :validate})}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
save_user(socket, user_params)
end
@impl true
def handle_event("cancel", _, socket) do
{:noreply, push_event(socket, "modal-please-hide", %{})}
end
def role_description(role) when is_atom(role) do
Keyword.get(User.roles(), role)
end
def role_description(role) when is_binary(role) do
Keyword.get(User.roles(), String.to_existing_atom(role))
end
end

View file

@ -0,0 +1,72 @@
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<div class="mb-3" phx-feedback-for="<%= input_id(f, :email)%>">
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_id(f, :email)
%>
<%= error_tag f, :email %>
</div>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="input-group mb-3">
<span class="input-group-text">
<%= icon_div @socket, "bi-shield-shaded", [class: "icon"] %>
</span>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
<div phx-feedback-for="<%= input_id(f, :password) %>">
<%= label f, :password, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
</div>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-secondary", phx_click: "cancel", phx_target: @myself %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -0,0 +1,191 @@
defmodule Shift73kWeb.UserManagementLive.Index do
use Shift73kWeb, :live_view
import Ecto.Query
import Shift73kWeb.Pagination
import Shift73k.Util.Dt
alias Shift73k.Repo
alias Shift73k.Accounts
alias Shift73k.Accounts.User
alias Shift73kWeb.Roles
@impl true
def mount(_params, session, socket) do
socket
|> assign_defaults(session)
|> assign(:page, nil)
|> live_okreply()
end
@impl true
def handle_params(params, _url, socket) do
current_user = socket.assigns.current_user
live_action = socket.assigns.live_action
user = user_from_params(params)
if Roles.can?(current_user, user, live_action) do
socket
|> assign(:query, query_map(params))
|> assign_modal_return_to()
|> assign(:delete_user, nil)
|> assign(:page, nil)
|> request_page_query()
|> apply_action(socket.assigns.live_action, params)
|> live_noreply()
else
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")
|> live_noreply()
end
end
defp request_page_query(%{assigns: %{query: query}} = socket) do
send(self(), {:do_page_query, query})
socket
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Users")
|> assign(:user, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New User")
|> assign(:user, %User{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit User")
|> assign(:user, Accounts.get_user!(id))
end
def assign_modal_return_to(%{assigns: %{query: query}} = socket) do
to = Routes.user_management_index_path(socket, :index, Enum.into(query, []))
assign(socket, :modal_return_to, to)
end
defp user_from_params(params)
defp user_from_params(%{"id" => id}),
do: Accounts.get_user!(id)
defp user_from_params(_params), do: %User{}
def query_map(params) do
%{
filter: params["filter"] || "",
sort_by: (params["sort_by"] in ~w(email inserted_at role) && params["sort_by"]) || "email",
sort_order: (params["sort_order"] == "desc" && "desc") || "asc",
page_number: String.to_integer(params["page_number"] || "1"),
page_size: String.to_integer(params["page_size"] || "10")
}
end
defp page_query(query) do
from(u in User)
|> or_where([u], ilike(u.email, ^"%#{query.filter}%"))
|> order_by([u], [
{^String.to_existing_atom(query.sort_order), ^String.to_existing_atom(query.sort_by)}
])
|> Repo.paginate(page: query.page_number, page_size: query.page_size)
end
@impl true
def handle_event("delete-modal", %{"id" => id}, socket) do
{:noreply, assign(socket, :delete_user, Accounts.get_user(id))}
end
@impl true
def handle_event("filter-change", params, socket) do
send(self(), {:query_update, Map.put(params, "page_number", "1")})
{:noreply, socket}
end
@impl true
def handle_event("filter-clear", _params, socket) do
send(self(), {:query_update, %{"filter" => "", "page_number" => "1"}})
{:noreply, socket}
end
@impl true
def handle_event(
"sort-change",
%{"sort_by" => column} = params,
%{assigns: %{query: query}} = socket
) do
(column == query.sort_by &&
send(
self(),
{:query_update, %{"sort_order" => (query.sort_order == "asc" && "desc") || "asc"}}
)) ||
send(self(), {:query_update, Map.put(params, "sort_order", "asc")})
{:noreply, socket}
end
@impl true
def handle_event("sort-by-change", %{"sort" => params}, socket) do
send(self(), {:query_update, params})
{:noreply, socket}
end
@impl true
def handle_event("sort-order-change", _params, socket) do
new_sort_order = (socket.assigns.query.sort_order == "asc" && "desc") || "asc"
send(self(), {:query_update, %{"sort_order" => new_sort_order}})
{:noreply, socket}
end
@impl true
def handle_event("page-change", params, socket) do
send(self(), {:query_update, params})
{:noreply, socket}
end
@impl true
def handle_event("page-size-change", params, socket) do
send(self(), {:query_update, Map.put(params, "page_number", "1")})
{:noreply, socket}
end
@impl true
def handle_info({:query_update, params}, %{assigns: %{query: q}} = socket) do
{:noreply,
push_patch(socket,
to: Routes.user_management_index_path(socket, :index, get_new_params(params, q)),
replace: true
)}
end
@impl true
def handle_info({:do_page_query, query}, socket) do
{:noreply, assign(socket, :page, page_query(query))}
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_return_to: to}} = socket) do
socket |> copy_flash() |> push_patch(to: to) |> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp get_new_params(params, query) do
[
{:filter, Map.get(params, "filter") || query.filter},
{:sort_by, Map.get(params, "sort_by") || query.sort_by},
{:sort_order, Map.get(params, "sort_order") || query.sort_order},
{:page_number, Map.get(params, "page_number") || query.page_number},
{:page_size, Map.get(params, "page_size") || query.page_size}
]
end
def dt_out(ndt), do: format_ndt(ndt, "{YYYY} {Mshort} {0D}, {h12}:{0m} {am}")
end

View file

@ -0,0 +1,216 @@
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.FormComponent,
id: @user.id || :new,
title: @page_title,
action: @live_action,
user: @user,
current_user: @current_user %>
<% end %>
<%= if @delete_user do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.DeleteComponent,
id: @delete_user.id,
title: "Delete User",
delete_user: @delete_user,
current_user: @current_user %>
<% end %>
<h2 class="mb-3">
<%= icon_div @socket, "bi-people", [class: "icon baseline"] %>
Listing Users
</h2>
<%# filtering and new item creation %>
<div class="d-flex flex-column flex-sm-row justify-content-between d-flex align-items-start mb-3">
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
class: "btn btn-primary mb-3 mb-sm-0" do %>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline me-1"] %>
New User
<% end %>
<%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %>
<div class="input-group">
<span class="input-group-text">
<%= icon_div @socket, "bi-filter", [class: "icon"] %>
</span>
<%= text_input flt, :filter, name: "filter", class: "form-control", placeholder: "Filter users...", value: @query.filter %>
</button>
<%= if @query.filter == "" do %>
<button class="btn btn-outline-secondary" type="button" aria-label="Clear filter" aria-disabled="true" disabled>
<% else %>
<button class="btn btn-outline-secondary" type="button" aria-label="Clear filter" phx-click="filter-clear">
<% end %>
<%= icon_div @socket, "bi-backspace", [class: "icon baseline"], [role: "img", aria_hidden: false] %>
</button>
</div>
<% end %>
</div>
<%# main data table %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
Email
<%= if @query.sort_by == "email", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
Role
<%= if @query.sort_by == "role", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
Created at
<%= if @query.sort_by == "inserted_at", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col">Confirmed?</th>
<th></th>
</tr>
</thead>
<tbody id="users">
<%= if !@page do %>
<tr>
<td class="text-center" colspan="5">
<div class="spinner-border text-primary my-5" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
<% else %>
<%= for user <- @page.entries do %>
<tr id="user-<%= user.id %>">
<td class="align-middle"><%= user.email %></td>
<td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td>
<td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td>
<td class="align-middle">
<%= if user.confirmed_at do %>
<span class="visually-hidden">Yes</span>
<%= icon_div @socket, "bi-check", [class: "icon baseline fs-4 text-success"], [role: "img", aria_hidden: false] %>
<% else %>
<span class="visually-hidden">No</span>
<%= icon_div @socket, "bi-x", [class: "icon baseline fs-4 text-warning"], [role: "img", aria_hidden: false] %>
<% end %>
</td>
<td class="align-middle text-end text-nowrap">
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
Edit
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
Delete
</button>
<% end %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
<%# pagination interface %>
<%= if @page do %>
<div class="d-flex flex-column flex-sm-row justify-content-between d-flex align-items-start my-3">
<%# <div class="d-flex justify-content-between d-flex align-items-start"> %>
<%# items per page selector %>
<div class="d-flex align-items-center mb-3 mb-sm-0">
<%= form_for :page_size, "#", [phx_change: "page-size-change"], fn pgsz -> %>
<%= select pgsz, :page_size,
[10, 15, 20, 30, 50, 100] |> Enum.map(fn n -> {"#{n} per page", n} end),
value: @query.page_size,
id: "table_page_size_page_size",
name: "page_size",
class: "form-select"
%>
<% end %>
<span class="ms-2"><%= @page.total_entries %> found</span>
</div>
<%# main pagination %>
<nav aria-label="User list page navigation">
<ul class="pagination mb-0">
<%# previous page button %>
<% icon = icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<%= if @page.page_number == 1 do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Previous</span>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number="<%= @page.page_number - 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
</li>
<%# page buttons %>
<%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %>
<%= cond do %>
<%= page_num < 1 -> %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true">&hellip;</span>
<span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span>
</li>
<% page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></a>
</li>
<% true -> %>
<li class="page-item">
<a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
</li>
<% end %>
<% end %>
<%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Next</span>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
</li>
</ul>
</nav>
</div>
<% end %>