LoginSignup
5
2

More than 1 year has passed since last update.

ElixirDesktopプロジェクトにphx.gen.auth + tailwind + DaisyUIで認証機能を実装

Last updated at Posted at 2022-09-24

はじめに

本記事は以下の記事で作成したプロジェクトに
1 Tailwind + DaisyUIでスマホUIの作成
2 phx.gen.authで認証機能
3 phx.gen.liveでCRUD作成
をそれぞれ実装していきます

tailwind追加

いつもどおり本家を参考に追加していきます

trarecord/mix.exs
defmodule Trarecord.MixProject do
  use Mix.Project

  defp deps do
    [
      ...
      {:tailwind, "~> 0.1", runtime: Mix.env() == :dev} # 追加
    ]
  end
end
trarecord/config/config.ex
import Config

...
# 以下追加
config :tailwind,
  version: "3.1.8",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

trarecord/mix.exs
defmodule Trarecord.MixProject do
  use Mix.Project

  ...
  defp aliases do
    [
      ...
      # assets.deploy差し替え
      "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] 
    ]
  end
end

desktopとwebにそれぞれ開発環境時にtailwindをホットリロードできるようにします

trarecord/config/desktop.exs
if config_env() == :dev do
  config :trarecord, TrarecordWeb.EndpointWeb,
    ...
    watchers: [
      # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
      esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
      tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} # 追加
    ]
  ...
end
trarecord/config/web.exs
if config_env() == :dev do
  config :trarecord, TrarecordWeb.EndpointWeb,
    ...
    watchers: [
      # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
      esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
      tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} # 追加
    ]
  ...
end

最後に関連ファイルをインストールします

mix tailwind.install

DaisyUI

こちらも公式通りにおこないます
npm関連はassets以下でおこなうのでcd assetsしておきましょう

cd assets
npm i daisyui
cd .. 
trarecord/assets/tailwind.config.js
let plugin = require('tailwindcss/plugin')

module.exports = {
  content: [
    './js/**/*.js',
    '../lib/*_web.ex',
    '../lib/*_web/**/*.*ex'
  ],
  theme: {
    extend: {},
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('daisyui'), // 追加
    ...
  ]
}

スマホアプリビルド時に npm iで関連ライブインストールするように前記事でコメントアウトした箇所をコメントインします

android/app/run_mix
# 以下をコメントイン
if [ ! -d "assets/node_modules" ]; then
  cd assets && npm i && cd ..
fi
ios/run_mix
# 以下をコメントイン
if [ ! -d "assets/node_modules" ]; then
  cd assets && npm i && cd ..
fi

TailwindとDaisyUIのセットアップは以上になります

認証機能実装

次に認証機能を実装します
例のごとくコマンド一発で完了

mix phx.gen.auth Users User users
mix deps.get
mix ecto.migrate

Bcrypt -> Pbkdf2 に変更

Android/iOSでパスワードハッシュライブラリがBcryptだとエラーになるのでライブラリをPbkdf2に変更します

trarecord/mix.exs
defmodule Trarecord.MixProject do
  use Mix.Project
  ...
  defp deps do
    [
      ...
      {:pbkdf2_elixir, "~> 2.0"}
    ]
  end
  ...
end
trarecord/lib/trarecord/users/user.ex
defmodule Trarecord.Users.User do
  use Ecto.Schema
  import Ecto.Changeset

  ...
  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      # If using Bcrypt, then further validate it is at most 72 bytes long
      |> validate_length(:password, max: 72, count: :bytes)
      |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password)) # Pbkdf2に変更
      |> delete_change(:password)
    else
      changeset
    end
  end

  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
  `Pbkdf2.no_user_verify/0` to avoid timing attacks. # Pbkdf2に変更
  """
  def valid_password?(%Trarecord.Users.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Pbkdf2.verify_pass(password, hashed_password) # Pbkdf2に変更
  end

  def valid_password?(_, _) do
    Pbkdf2.no_user_verify() # Pbkdf2に変更
    false
  end

end

ログイン前の画面

desktopの方にはリロードボタンがなくて面倒なので基本ブラウザで開発していきます
デベロッパーコンソール等でスマホサイズに画面をしておきましょう

MIX_TARGET=host iex -S mix phx.server

トップページ

header箇所を削除

headerタグを削除します
ついでにbg-colorもセットします

trarecord/lib/trarecord_web/templates/layout/root.html.heex
<!DOCTYPE html>
<html lang="en">
  <head>
  ...
  </head>
 <body class="bg-base-200">
    <%= @inner_content %>
  </body>
</html>

トップページの作成

daisyuiのheroのサンプルを貼り付けます

priv/static/images に top.pngという名前でおしゃれな写真を入れておいてください
pタグ内にそれっぽい内容に書き換えます

trarecord/lib/trarecord_web/templates/page/index.html.heex
<div class="hero min-h-screen" style={"background-image: url(#{Routes.static_path(@conn, "/images/top.png")});"}>
  <div class="hero-overlay bg-opacity-60"></div>
  <div class="hero-content text-center text-neutral-content">
    <div class="max-w-md">
      <h1 class="mb-5 text-5xl font-bold">Welcome to Trarecord</h1>
      <p class="mb-5">Trarecord is travel note. we provides pick up spot, record visited spot information and more...</p>
      <%= link "Get Started", to: Routes.user_registration_path(@conn, :new), class: "btn btn-primary" %>
    </div>
  </div>
</div>

スクリーンショット 2022-09-08 21.05.35.png

各種 phx.gen.authのページをスタイリング

基本的にはTailblockのCTAの2つ目をベースにします

register

trarecord/lib/trarecord_web/templates/user_registration/new.html.heex
<div class="hero min-h-screen">
  <div class="hero-content flex-col lg:flex-row-reverse">
    <div class="text-center lg:text-left">
      <h1 class="text-5xl font-bold">Register now!</h1>
      <p class="py-6">Trarecord is Travel notes.</p>
      <p class="py6">pick up want to spot. record visited spot information and more...</p>
    </div>
    <div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
      <div class="card-body">
        <.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
          <%= if @changeset.action do %>
            <div class="alert alert-danger">
              <p>Oops, something went wrong! Please check the errors below.</p>
            </div>
          <% end %>
          <div class="form-control">
            <%= label f, :email, class: "label" %>
            <%= email_input f, :email, required: true, class: "input input-bordered" %>
            <%= error_tag f, :email %>
          </div>

          <div class="form-control">
            <%= label f, :password, class: "label" %>
            <%= password_input f, :password, required: true, class: "input input-bordered" %>
            <%= error_tag f, :password %>
          </div>

          <label class="label">
            <%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "label-text-alt link link-hover" %>
          </label>
          <label class="label">
            <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new), class: "label-text-alt link link-hover" %>
          </label>

          <div class="form-control mt-6">
            <%= submit "Register", class: "btn btn-primary" %>
          </div>
        </.form>
      </div>
    </div>
  </div>
</div>

スクリーンショット 2022-09-08 21.07.17.png

Login

trarecord/lib/trarecord_web/templates/user_session/new.html.heex
<div class="hero min-h-screen">
  <div class="hero-content flex-col lg:flex-row-reverse">
    <div class="text-center lg:text-left">
      <h1 class="text-5xl font-bold">Login now!</h1>
      <p class="py-6">Trarecord is Travel notes.</p>
      <p class="py-6">pick up want to spot. record visited spot information and more...</p>
    </div>
    <div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
      <div class="card-body">
        <.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}>
          <%= if @error_message do %>
            <div class="alert alert-danger">
              <p><%= @error_message %></p>
            </div>
          <% end %>
          <div class="form-control">
            <%= label f, :email, class: "label" %>
            <%= email_input f, :email, required: true, class: "input input-bordered" %>
            <%= error_tag f, :email %>
          </div>

          <div class="form-control">
            <%= label f, :password, class: "label" %>
            <%= password_input f, :password, required: true, class: "input input-bordered" %>
            <%= error_tag f, :password %>
          </div>

          <label class="label">
            <%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "label-text-alt link link-hover" %> 
          </label>
          <label class="label">
            <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new), class: "label-text-alt link link-hover" %>
          </label>

          <div class="form-control mt-6">
            <%= submit "Login", class: "btn btn-primary" %>
          </div>
        </.form>
      </div>
    </div>
  </div>
</div>

ほぼ同じなので割愛

forgot password?

こちらはTailBlockのContact1つめのフォーム部分のみ流用しています

trarecord/lib/trarecord_web/templates/user_reset_password/new.html.heex
<section class="text-gray-600 body-font relative">
  <div class="container px-5 py-24 mx-auto">
    <div class="flex flex-col text-center  md:w-2/3 mx-auto mb-12">
      <h1 class="normal-case text-4xl">Trarecord</h1>
    </div>    
    <div class="lg:w-1/2 md:w-2/3 flex flex-col mb-12 mx-auto">
      <h1 class="sm:text-3xl text-2xl font-medium title-font mb-4 text-gray-900">Forgot your password?</h1>
    </div>
    <div class="lg:w-1/2 md:w-2/3 mx-auto">
      <.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}>
        <%= label f, :email, class: "label" %>
        <%= email_input f, :email, required: true, class: "input input-bordered w-full" %>

        <div>
          <%= submit "Send instructions to reset password", class: "btn btn-primary mt-6" %>
        </div>
      </.form>

      <p class="mt-6">
        <%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "label-text-alt link link-hover" %>  |
        <%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "label-text-alt link link-hover" %>
      </p>
    </div>
  </div>
</section>

スクリーンショット 2022-09-08 21.08.30.png

trarecord/lib/trarecord_web/templates/user_reset_password/edit.html.heex
<section class="text-gray-600 body-font relative">
  <div class="container px-5 py-24 mx-auto">
    <div class="flex flex-col text-center  md:w-2/3 mx-auto mb-12">
      <h1 class="normal-case text-4xl">Trarecord</h1>
    </div>    
    <div class="lg:w-1/2 md:w-2/3 flex flex-col mb-12 mx-auto">
      <h1 class="sm:text-3xl text-2xl font-medium title-font mb-4 text-gray-900">Reset password</h1>
    </div>
    <div class="lg:w-1/2 md:w-2/3 mx-auto">
      <.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}>
        <%= if @changeset.action do %>
          <div class="alert alert-danger">
            <p>Oops, something went wrong! Please check the errors below.</p>
          </div>
        <% end %>

        <%= label f, :password, "New password", class: "label" %>
        <%= password_input f, :password, required: true, class: "input input-bordered w-full" %>
        <%= error_tag f, :password %>

        <%= label f, :password_confirmation, "Confirm new password", class: "label" %>
        <%= password_input f, :password_confirmation, required: true, class: "input input-bordered w-full" %>
        <%= error_tag f, :password_confirmation %>

        <div class="mt-6">
          <%= submit "Reset password", class: "btn btn-primary" %>
        </div>
      </.form>

      <p class="mt-6">
        <%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "label-text-alt link link-hover" %>  |
        <%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "label-text-alt link link-hover" %>
      </p>
    </div>
  </div>
</section>

ほとんど同じなので割愛

ログイン後の画面

live_viewと通常のcontoller両方で使えるように Phoenix.Componentで共通コンポーネントを作成します
headerは左側を戻るボタン、右側をアクションを指定できるようにします

Navigation

trarecord/lib/trarecord_web/live/components/navigation.ex
defmodule TrarecordWeb.Components.Navigation do
  use Phoenix.Component

  def header(assigns) do
    ~H"""
    <div class="fixed z-20 navbar bg-primary text-primary-content w-full">
      <div class="navbar-start">
        <%= if @back do %>
          <%= live_redirect "Back", to: @back, class: "btn btn-ghost normal-case text-xl" %>
        <% end %>
      </div>
      <div class="navbar-center">
        <a class="btn btn-ghost normal-case text-4xl"><%= @title %></a>
      </div>
      <div class="navbar-end">
        <%= if @action do %>
          <%= @action %>
        <% end %>
      </div>
    </div>
    """
  end

  def bottom_tab(assigns) do
    ~H"""
    <div class="btm-nav">
    <button class={if @title == "Home", do: "active", else: ""}>
     <a href="/home">
       <span class="btm-nav-label">Home</span>
     </a>
    </button>
    <button class={if @title == "Items", do: "active", else: ""}>
     <a href="/home">
       <span class="btm-nav-label">Items</span>
     </a>
    </button>

    <button class={if @title == "Setting", do: "active", else: ""}>
     <a href="users/settings">
       <span class="btm-nav-label">Setting</span>
     </a>
    </button>
    </div>
    """
  end
end

現在はログイン後のHome, CRUD予定のItems, 3つ目にSettingになります

Home

共通コンポーネントのヘッダーと下タブができたのでログイン後のホーム画面に追加します

trarecord/lib/trarecord_web/live/home_live.ex
defmodule TrarecordWeb.HomeLive do
  use TrarecordWeb, :live_view
  alias TrarecordWeb.Components.Navigation

  @impl true
  def mount(_args, _session, socket) do
    socket
    |> assign(:title, "Home")
    |> then(&{:ok, &1})
  end
end

画面構成はDaisyUIのHeroにします

trarecord/lib/trarecord_web/live/home_live.html.heex
<Navigation.header title={@title} back={false} action={false} />
<div class="hero min-h-screen bg-base-200">
  <div class="hero-content text-center">
    <div class="max-w-md">
      <h1 class="text-5xl font-bold">Hello there</h1>
      <p class="py-6">Provident cupiditate voluptatem et in. Quaerat fugiat ut assumenda excepturi exercitationem quasi. In deleniti eaque aut repudiandae et a id nisi.</p>
      <button class="btn btn-primary">Get Started</button>
    </div>
  </div>
</div>
<Navigation.bottom_tab title={@title} />

最後にrouterに追加して完了です

trarecord/lib/trarecord_web/router.ex
defmodule TrarecordWeb.Router do
  use TrarecordWeb, :router
  ...
  scope "/", TrarecordWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
    live "/home", HomeLive # 追加
  end  
  ...
end

Settingページ

phx.gen.authのsettings_controllerを表示します

ベースはTailBlockのContact1つめのフォーム部分

共通コンポーネントのaliasはcontrollerではなくviewですので注意が必要です

trarecord/lib/trarecord_web/views/user_settings_view.ex
defmodule TrarecordWeb.UserSettingsView do
  use TrarecordWeb, :view

  alias TrarecordWeb.Components.Navigation # 追加
end
trarecord/lib/trarecord_web/templates/user_settings/edit.html.heex
<Navigation.header title="Setting" back={false} action={false} />
<section class="text-gray-600 body-font relative">
  <div class="container px-5 py-24 mx-auto">
    <div class="lg:w-1/2 md:w-2/3 flex flex-col mb-12 mx-auto">
      <h1 class="sm:text-3xl text-2xl font-medium title-font mb-4 text-gray-900">Settings</h1>
    </div>
    <div class="lg:w-1/2 md:w-2/3 mx-auto">
      <div class="mt-5">
        <h3 class="text-xl text-gray-900">Change email</h3>

        <.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
          <%= if @email_changeset.action do %>
            <div class="alert alert-danger">
              <p>Oops, something went wrong! Please check the errors below.</p>
            </div>
          <% end %>

          <%= hidden_input f, :action, name: "action", value: "update_email" %>

          <%= label f, :email, class: "label" %>
          <%= email_input f, :email, required: true, class: "input input-bordered w-full" %>
          <%= error_tag f, :email %>

          <%= label f, :current_password, for: "current_password_for_email", class: "label" %>
          <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "input input-bordered w-full" %>
          <%= error_tag f, :current_password %>

          <div class="mt-5">
            <%= submit "Change email", class: "btn btn-block btn-primary" %>
          </div>
        </.form>
      </div>
      <div class="mt-6">
        <h3 class="text-xl text-gray-900">Change password</h3>

        <.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
          <%= if @password_changeset.action do %>
            <div class="alert alert-danger">
              <p>Oops, something went wrong! Please check the errors below.</p>
            </div>
          <% end %>

          <%= hidden_input f, :action, name: "action", value: "update_password" %>

          <%= label f, :password, "New password", class: "label" %>
          <%= password_input f, :password, required: true, class: "input input-bordered w-full" %>
          <%= error_tag f, :password %>

          <%= label f, :password_confirmation, "Confirm new password", class: "label" %>
          <%= password_input f, :password_confirmation, required: true, class: "input input-bordered w-full" %>
          <%= error_tag f, :password_confirmation %>

          <%= label f, :current_password, for: "current_password_for_password", class: "label" %>
          <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "input input-bordered w-full" %>
          <%= error_tag f, :current_password %>

          <div class="mt-6">
            <%= submit "Change password", class: "btn btn-block btn-primary" %>
          </div>
        </.form>
      </div>
      <div class="mt-6">
        <%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete, class: "btn btn-block btn-error" %>
      </div>

    </div>
  </div>
</section>
<Navigation.bottom_tab title="Setting" />

ページの最後にログアウトボタンも追加しておきます

トップページ切り替え

次にログイン時はHome、非ログイン時にはユーザー登録ページにリダイレクトするようにします

trarecord/lib/trarecord_web/controllers/page_controller.ex
defmodule TrarecordWeb.PageController do
  use TrarecordWeb, :controller

  alias Trarecord.Users.User

  def index(conn, _params) do
    redirect_to(conn, conn.assigns.current_user)
  end

  def redirect_to(conn, %User{}) do
    redirect(conn, to: Routes.live_path(TrarecordWeb.Endpoint, TrarecordWeb.HomeLive))
  end

  def redirect_to(conn, nil) do
    redirect(conn, to: Routes.user_registration_path(TrarecordWeb.Endpoint, :new))
  end
end

300a38b588d71912c69618bc716aa907.gif

Flashをtoastに変更

今のままだとヘッダーの上の段に出てしまうので、スマホでよくある右下に出るやつに変更します

fadeoutアニメーションが無いのでこちらを参考に追加します

trarecord/assets/tailwind.config.js
let plugin = require('tailwindcss/plugin')

module.exports = {
  content: [...],
  theme: {
    extend: {
      // ここから
      keyframes: {
        disappear: {
          "0%": { opacity: 1 },
          "100%": { opacity: 0 },
        },
      },
      animation: {
        disappear: "disappear 3s ease 2s 1 forwards",
      },
      // ここまで追加
    },
  },
  plugins: [...]
}

コンポーネントはこちら

appは通常のcontrollerで表示するページで適応されます
下タブに重ならないようにz-40としています

trarecord/lib/trarecord_web/templates/layout/app.html.heex
<main class="container">
  <div class="toast toast-end z-40 animate-disappear">
      <div>
        <p 
          class={"alert alert-info " <> if is_nil(get_flash(@conn, :info)), do: "hidden", else: ""}
          role="alert"
        ><%= get_flash(@conn, :info) %></p>
      </div>
      <div>
        <p 
          class={"alert alert-danger " <> if is_nil(get_flash(@conn, :error)), do: "hidden", else: ""}
          role="alert"
        ><%= get_flash(@conn, :error) %></p>
      </div>
  </div>
  <%= @inner_content %>
</main>

liveは名前の通りLiveViewで表示するページで適応されます

trarecord/lib/trarecord_web/templates/layout/live.html.heex
<main class="container">
  <div class="toast toast-end z-40 animate-disappear">
    <div>
      <p class={"alert alert-info " <> if is_nil(live_flash(@flash, :info)), do: "hidden", else: ""}
          role="alert"
          phx-click="lv:clear-flash"
          phx-value-key="info">
        <%= live_flash(@flash, :info) %>
      </p>
    </div>
    <div>
      <p  class={"alert alert-danger " <> if is_nil(live_flash(@flash, :error)), do: "hidden", else: ""}
          role="alert"
          phx-click="lv:clear-flash"
          phx-value-key="error">
        <%= live_flash(@flash, :error) %>
      </p>
    </div>
  </div>
  <%= @inner_content %>
</main>

d98eb4ddee1a569b06bc47d0dd32f520.gif

CRUD画面の追加

最後にCRUD画面を作ります
例のごとくphx.gen.liveで
リレーションまで加えると長くなるので今回は割愛します

mix phx.gen.live Items Item items name:string url:string image:string category:string status:boolean
mix ecto.migrate

出てきたルーティングを追加します

trarecord/lib/trarecord_web/router.ex
defmodule TrarecordWeb.Router do
  use TrarecordWeb, :router

  ...
  scope "/", TrarecordWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
    live "/settings", SettingLive.Index, :index
    live "/home", HomeLive
    
    # 以下追加
    live "/items", ItemLive.Index, :index
    live "/items/new", ItemLive.Index, :new
    live "/items/:id/edit", ItemLive.Index, :edit

    live "/items/:id", ItemLive.Show, :show
    live "/items/:id/show/edit", ItemLive.Show, :edit
  end
  ...
end

confirm modalの実装

ネイティブアプリだとalertやconfirmダイアログがでないので、モーダルで表示するようにします

trarecord/lib/trarecord_web/live/item_live/confirm_component.ex
defmodule TrarecordWeb.ItemLive.ConfirmComponent do
  use TrarecordWeb, :live_component

  alias Trarecord.Items
  alias Phoenix.LiveView.JS

  @impl true
  def update(%{item: _item} = assigns, socket) do
    {:ok, assign(socket, assigns)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    item = Items.get_item!(id)
    {:ok, _} = Items.delete_item(item)

    socket
    |> put_flash(:info, "Item delete successfully")
    |> push_redirect(to: socket.assigns.return_to)
    |> then(&{:noreply, &1})
  end

  defp list_items do
    Items.list_items()
  end
end

cancelボタンは live_hlperに実装されているモーダルの閉じるボタンへのクリックイベントを発火させています

trarecord/lib/trarecord_web/live/live_helpers.ex
<a id="close" href="#" class="phx-modal-close" phx-click={hide_modal()}></a>

  defp hide_modal(js \\ %JS{}) do
    js
    |> JS.hide(to: "#modal", transition: "fade-out")
    |> JS.hide(to: "#modal-content", transition: "fade-out-scale")
  end
trarecord/lib/trarecord_web/live/item_live/confirm_component.html.heex
<div class="card w-80">
  <div class="card-body items-center text-center">
    <h2 class="card-title mb-4">Are you sure?</h2>
    <div class="card-actions justify-end">
      <button class="btn btn-ghost" phx-click={JS.dispatch("click", to: "#close")}>Cancel</button>
      <button class="btn btn-error" 
              phx-click="delete" 
              phx-value-id={@item.id}
              phx-target={@myself}
      >Delete</button>
    </div>
  </div>
</div>

indexとshowからのルーティングを追加します

trarecord/lib/trarecord_web/router.ex
defmodule TrarecordWeb.Router do
  use TrarecordWeb, :router

  ...
  scope "/", TrarecordWeb do
    pipe_through([:browser, :require_authenticated_user])

    get("/users/settings", UserSettingsController, :edit)
    put("/users/settings", UserSettingsController, :update)
    get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
    live("/settings", SettingLive.Index, :index)
    live("/home", HomeLive)

    live("/items", ItemLive.Index, :index)
    live("/items/new", ItemLive.Index, :new)
    live("/items/:id/edit", ItemLive.Index, :edit)
    live("/items/:id/delete", ItemLive.Index, :delete) # 追加

    live("/items/:id", ItemLive.Show, :show)
    live("/items/:id/show/edit", ItemLive.Show, :edit)
    live("/items/:id/show/delete", ItemLive.Show, :delete) # 追加
  end

フォームモーダルのスタイリング

inputとlabelにクラスを追加します

trarecord/lib/trarecord_web/live/item_live/form_component.html.heex
<div>
  <h2 class="sm:text-3xl text-2xl font-medium title-font mb-4 text-gray-900"><%= @title %></h2>

  <.form
    let={f}
    for={@changeset}
    id="item-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="save">

    <%= label f, :name, class: "label" %>
    <%= text_input f, :name, class: "input input-bordered w-full" %>
    <%= error_tag f, :name %>
  
    <%= label f, :url, class: "label" %>
    <%= text_input f, :url, class: "input input-bordered w-full" %>
    <%= error_tag f, :url %>
  
    <%= label f, :image, class: "label" %>
    <%= text_input f, :image, class: "input input-bordered w-full" %>
    <%= error_tag f, :image %>
  
    <%= label f, :category, class: "label" %>
    <%= text_input f, :category, class: "input input-bordered w-full" %>
    <%= error_tag f, :category %>
  
    <%= label f, "Have It", class: "label" %>
    <%= checkbox f, :status,class: "checkbox" %>
    <%= error_tag f, :status %>
  
    <div>
      <%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-primary mt-4" %>
    </div>
  </.form>
</div>

モーダルのスタイリング

といっても角を丸くするだけです

trarecord/lib/trarecord_web/live/live_helpers.ex
defmodule TrarecordWeb.LiveHelpers do
  import Phoenix.LiveView
  import Phoenix.LiveView.Helpers

  alias Phoenix.LiveView.JS
  def modal(assigns) do
    assigns = assign_new(assigns, :return_to, fn -> nil end)

    ~H"""
    <div id="modal" class="phx-modal fade-in" phx-remove={hide_modal()}>
      <div
        id="modal-content"
        class="phx-modal-content fade-in-scale rounded-lg" # rouded-lgを追加
        phx-click-away={JS.dispatch("click", to: "#close")}
        phx-window-keydown={JS.dispatch("click", to: "#close")}
        phx-key="escape"
      >
      ...
      </div>
    </div>
    """
  end
  ...
end

indexのスタイリング

コンポーネントの追加とモーダルに切り出したdelete部分を改修します

trarecord/lib/trarecord_web/live/item_live/index.ex
defmodule TrarecordWeb.ItemLive.Index do
  use TrarecordWeb, :live_view

  alias Trarecord.Items
  alias Trarecord.Items.Item
  alias TrarecordWeb.Components.Navigation # 追加
  ... 

+  defp apply_action(socket, :delete, %{"id" => id}) do
+    socket
+    |> assign(:page_title, "Delete Item")
+    |> assign(:item, Items.get_item!(id))
+  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Items")
    |> assign(:item, nil)
  end

-  @impl true
-  def handle_event("delete", %{"id" => id}, socket) do
-    item = Items.get_item!(id)
-    {:ok, _} = Items.delete_item(item)
-
-    {:noreply, assign(socket, :items, list_items())}
-  end
end

Headerはbackは無いのでfalse, actionは新規追加モーダル表示用のリンクを追加します

デザインはECOMMERCEの1つめをベースにします

実際の表示部分はCardコンポーネントを使っています

trarecord/lib/trarecord_web/live/item_live/index.html.heex
<Navigation.header 
  title="Items" 
  back={false} 
  action={live_patch "Add Item", to: Routes.item_index_path(@socket, :new), class: "btn btn-ghost normal-case"}
/>
<section class="text-gray-600 body-font relative">
  <div class="container px-5 py-24 mx-auto">
    <div class="lg:w-1/2 md:w-2/3 flex flex-col mb-4 mx-auto">
      <h1 class="sm:text-3xl text-2xl font-medium title-font text-gray-900">Listing Items</h1>
    </div>
    <section>
      <div class="container px-5 py-4 mx-auto">
        <div class="flex flex-wrap -m-4">
          <%= for item <- @items do %>
            <div id={"item-#{item.id}"} class="card card-compact bg-base-100 shadow-2xl lg:w-1/4 md:w-1/2 p-4 w-full mb-4">
              <figure><img src={item.image} alt="" /></figure>
              <div class="card-body">
                <h2 class="card-title">
                  <%= item.name %>
                  <%= if item.status do %>
                    <div class="badge badge-secondary">Have It</div>
                  <% else %>
                    <div class="badge badge-secondary">Wants It</div>
                  <% end %>
                  <div class="badge"><%= item.category %></div>
                </h2>
                
                <a class="link" href={item.url}><%= item.url %></a>
              </div>
              <div class="card-actions justify-end">
                <span><%= live_redirect "Show", to: Routes.item_show_path(@socket, :show, item), class: "btn normal-case" %></span>
                <span><%= live_patch "Edit", to: Routes.item_index_path(@socket, :edit, item), class: "btn normal-case" %></span>
                <!--- deleteを live_patchにしてモーダルを表示するように変更 --->
                <span><%= live_patch "Delete", to: Routes.item_index_path(@socket, :delete, item), class: "btn btn-error normal-case" %></span>
              </div>
            </div>
          <% end %>
        </div>
      </div>
    </section>

    <%= if @live_action in [:new, :edit] do %>
      <.modal return_to={Routes.item_index_path(@socket, :index)}>
        <.live_component
          module={TrarecordWeb.ItemLive.FormComponent}
          id={@item.id || :new}
          title={@page_title}
          action={@live_action}
          item={@item}
          return_to={Routes.item_index_path(@socket, :index)}
        />
      </.modal>
    <% end %>

    <!--- 以下追加 --->
    <%= if @live_action in [:delete] do %>
      <.modal return_to={Routes.item_index_path(@socket, :index)}>
        <.live_component
          module={TrarecordWeb.ItemLive.ConfirmComponent}
          id={@item.id}
          title={@page_title}
          action={@live_action}
          item={@item}
          return_to={Routes.item_index_path(@socket, :index)}
        />
      </.modal>
    <% end %>
  </div>
</section>
<Navigation.bottom_tab title="Items" />

Showのスタイリング

コンポーネントの追加と deleteモーダルのpage_titleを追加します

trarecord/lib/trarecord_web/live/item_live/show.ex
efmodule TrarecordWeb.ItemLive.Show do
  use TrarecordWeb, :live_view

  alias Trarecord.Items
  alias TrarecordWeb.Components.Navigation # 追加

  ...
  defp page_title(:show), do: "Show Item"
  defp page_title(:edit), do: "Edit Item"
  defp page_title(:delete), do: "Delete Item" # 追加
end

headerのbackにindex, actionにedit ページ下部に削除ボタンをつけました
削除モーダルですが
.modal の return_toはモーダルを閉じた時に戻る箇所
.live_componentのreturn_toは削除後に戻る箇所になります

閉じたらshow、削除したらindexに戻るようにします

trarecord/lib/trarecord_web/live/item_live/show.html.heex
<Navigation.header 
  title="Items" 
  back={Routes.item_index_path(@socket, :index)} 
  action={live_patch "Edit", to: Routes.item_show_path(@socket, :edit, @item), class: "btn btn-ghost normal-case text-xl"}
/>
<section class="text-gray-600 body-font relative">
  <div class="container px-5 py-24 mx-auto">
    <div class="lg:w-1/2 md:w-2/3 flex flex-col mb-4 mx-auto">
      <h1 class="sm:text-3xl text-2xl font-medium title-font text-gray-900">Show Item</h1>
    </div>

    <%= if @live_action in [:edit] do %>
      <.modal return_to={Routes.item_show_path(@socket, :show, @item)}>
        <.live_component
          module={TrarecordWeb.ItemLive.FormComponent}
          id={@item.id}
          title={@page_title}
          action={@live_action}
          item={@item}
          return_to={Routes.item_show_path(@socket, :show, @item)}
        />
      </.modal>
    <% end %>

    <%= if @live_action in [:delete] do %>
      <.modal return_to={Routes.item_show_path(@socket, :show, @item)}>
        <.live_component
          module={TrarecordWeb.ItemLive.ConfirmComponent}
          id={@item.id}
          title={@page_title}
          action={@live_action}
          item={@item}
          return_to={Routes.item_index_path(@socket, :index)}
        />
      </.modal>
    <% end %>

    <ul>

      <li>
        <strong>Name:</strong>
        <%= @item.name %>
      </li>

      <li>
        <strong>Url:</strong>
        <a href={@item.url}><%= @item.url %></a>
      </li>

      <li>
        <strong>Image:</strong>
        <img class="object-contain w-1/2" src={@item.image} />
      </li>

      <li>
        <strong>Category:</strong>
        <%= @item.category %>
      </li>

      <li>
        <strong>Have It:</strong>
        <%= @item.status %>
      </li>

    </ul>
    <span><%= live_patch "Delete", to: Routes.item_show_path(@socket, :delete, @item), class: "btn btn-block btn-error normal-case" %></span>
  </div>
</section>
<Navigation.bottom_tab title="Items" />

画面は以上になります

DEMO

7adbe0634b5992a44e33f89bc3900928.gif

最後に

スタイリングしたため膨大な記事になりましたが
グローバルナビゲーションとconfirmモーダルをつければ割と簡単にスマホアプリが作れれそうです!

今は NIFを使うライブラリが使えないですが、iOSでEvision(openCV wrapper)が動いたらしいので、
NIFを使うライブラリもそのうち使えるようなるかもしれません本時期は以上になりますありがとうございました

コード

参考サイト

https://qiita.com/the_haigo/items/27bf18acd28971af31e9
https://tailwindcss.com/docs/guides/phoenix
https://daisyui.com/docs/install/
https://daisyui.com/components/hero/#hero-with-overlay-image
https://daisyui.com/components/hero/#centered-hero
https://daisyui.com/components/card/#card-with-badge
https://daisyui.com/components/toast/#toast-with-alert-inside
https://github.com/riverrun/pbkdf2_elixir
https://tailblocks.cc/
https://github.com/mertJF/tailblocks/blob/master/src/blocks/cta/light/b.js
https://github.com/mertJF/tailblocks/blob/master/src/blocks/contact/light/a.js
https://github.com/mertJF/tailblocks/blob/master/src/blocks/ecommerce/light/a.js
https://zenn.dev/k_neko3/articles/1aee5b86d07f4f

5
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2