ElixirDesktopでスマホ的なUIでアプリを作る〜API通信と保存〜

# はじめに
本記事は以下の記事で作成したCRUDアプリにスマホなUIを組み込んでいく内容になります

https://qiita.com/the_haigo/items/65160612eff12ebfecc2

前提
- 本棚アプリ
- サーバーとの通信はしないスタンドアローン
- DBはローカルのSQlite3
- 本棚(shelf)のCRUD画面がある
- confirm modal を作成してある
- [ナビゲーション実装](https://qiita.com/the_haigo/items/eff6fd48f07b7ae73069)

# Books context　の作成

本を保存するコンテキストと画面を作成します

```
mix phx.gen.live Books Book books title:string author:string thumb:string isbn:string published_date:date shelf_id:references:shelves
```

WxWidgetで開発用にマイグレーションを行います

```
mix ecto.migrate
```

アプリ用マイグレーションを作成します
`priv/repo/migrations/xxxx_create_books.exs` を
`lib/ebookwormer/migrations` にコピーして exs -> exに変更します

スキーマファイルにリレーションの設定を行います

```diff_elixir:lib/ebookwormer/books/book.ex
defmodule Ebookworm.Books.Book do
  use Ecto.Schema
  import Ecto.Changeset

  schema "books" do
    field :author, :string
    field :isbn, :string
    field :published_date, :date
    field :thumb, :string
    field :title, :string
-   field :shelf_id, :id
+   belongs_to :shelf, EbookWorm.Shelves.Shelf

    timestamps()
  end

  @doc false
  def changeset(book, attrs) do
    book
-   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date])
-   |> validate_required([:title, :author, :thumb, :isbn, :published_date])
+   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date, :shelf_id])
+   |> validate_required([:title, :author, :thumb, :isbn, :published_date, :shelf_id])
  end
end
```

関連先のshelfにhas_manyの設定をします

```ib/ebookworm/shelves/shelf.ex
defmodule Ebookworm.Shelves.Shelf do
  use Ecto.Schema
  import Ecto.Changeset

  schema "shelves" do
    field :name, :string
    # 追加
    has_many :books, Ebookworm.Books.Book

    timestamps()
  end

  @doc false
  def changeset(shelf, attrs) do
    shelf
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end
```

画面とルーティングは後ほど


# API通信 Google Books API

ボトムタブナビゲーションができて保存用のスキーマができたので、書籍検索画面を作ります

APIはGoogle Books APIを使用します
本来はGoogleのアカウントに紐づいた自分の本棚を操作する　APIですが
書籍検索に限ってAPI Key等が必要なく使用できるのでこちらを使用します

https://developers.google.com/books/docs/v1/using?hl=ja#WorkingVolumes

APIを叩くライブラリはReqを使用します

https://hexdocs.pm/req/readme.html

```diff_elixir:mix.exs
defmodule Ebookworm.MixProject do
  use Mix.Project

  ...
  defp deps do
    [
       ...
-      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]}
+      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]},
+      {:req, "~> 0.4.0"} 
    ]
  end
end
```

```
mix deps.get
```

ライブラリを追加したら検索ページを作成します

```lib/ebookworm_web/router.ex
  scope "/", EbookwormWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/shelves", ShelfLive.Index, :index
    live "/shelves/new", ShelfLive.Index, :new
    live "/shelves/:id/edit", ShelfLive.Index, :edit
    live "/shelves/:id/delete", ShelfLive.Index, :delete

    live "/shelves/:id", ShelfLive.Show, :show
    live "/shelves/:id/show/edit", ShelfLive.Show, :edit
    # 追加
    live "/search", SearchLive.Index, :index 
  end
```

ヘッダーとフォーム、ボトムタブナビゲーションを追加します


```elixir:lib/ebookworm_web/live/search_live/index.ex
defmodule EbookwormWeb.SearchLive.Index do
  use EbookwormWeb, :live_view

  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title="Search" />

    <div class="min-h-[120px]">
      <form id="book-form" class="flex w-full" phx-submit="search">
        <div class="w-3/4">
          <.input name="keyword" value="" type="text" />
        </div>
        <.button class="w-1/4 mt-2 ml-2 h-10" phx-disable-with="Searching...">Search</.button>
      </form>
    </div>
    <.bottom_tab title="Search" />
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Search Books")
     |> stream(:books, [])}
  end

  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
    IO.inspect(keyword)
    {:noreply, socket}
  end
end
```

Searchボタンを押して入力したキーワードが取得できるのを確認できたら、API通信部分を書きます


```diff_elixir:lib/ebookworm_web/live/search_live/index.ex
  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
-   IO.inspect(keyword)
+   endpoint = "https://www.googleapis.com/books/v1/volumes"
+   data =
+     Req.get!(endpoint, params: [q: keyword, maxResults: 40])
+     |> IO.inspect()
+   {:noreply, socket}
  end
```

Reqの引数は、URLを第１引数、リクエストパラメータを、paramsをキーにキーワードリストで渡します

APIの使用回数が増えてきたら、上限ですよとエラーが返ってきますので、paramsにgoogle developer consoleのAPIキーを追加して再度実行します

```elixir
%Req.Response{
  status: 429,
  headers: %{...},
  body: %{
    "error" => %{
      "code" => 429,
      "details" => [
        %{
          "@type" => "type.googleapis.com/google.rpc.ErrorInfo",
          "domain" => "googleapis.com",
          "metadata" => %{
            "consumer" => "----",
            "quota_limit" => "defaultPerDayPerProject",
            "quota_limit_value" => "20000000",
            "quota_location" => "global",
            "quota_metric" => "books.googleapis.com/default",
            "service" => "books.googleapis.com"
          },
          "reason" => "RATE_LIMIT_EXCEEDED"
        },
        %{
          "@type" => "type.googleapis.com/google.rpc.Help",
          "links" => [
            %{
              "description" => "Request a higher quota limit.",
              "url" => "https://cloud.google.com/docs/quota#requesting_higher_quota"
            }
          ]
        }
      ],
      "errors" => [
        %{
          "domain" => "global",
          "message" => "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'books.googleapis.com' for consumer 'project_number:----'.",
          "reason" => "rateLimitExceeded"
        }
      ],
      "message" => "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'books.googleapis.com' for consumer 'project_number:---'.",
      "status" => "RESOURCE_EXHAUSTED"
    }
  },
  trailers: %{},
  private: %{}
}
```

APIキーを追加しても、Google Books APIが有効でない場合は以下のようなエラーが返ってきますので、該当のURLを開いてサービスを有効化しましょう


```elixir
%Req.Response{
  status: 403,
  headers: %{...},
  body: %{
    "error" => %{
      "code" => 403,
      "details" => [
        %{
          "@type" => "type.googleapis.com/google.rpc.Help",
          "links" => [
            %{
              "description" => "Google developers console API activation",
              "url" => "https://console.developers.google.com/apis/api/books.googleapis.com/overview?project=-----"
            }
          ]
        },
      ....
      "status" => "PERMISSION_DENIED"
    }
  },
  trailers: %{},
  private: %{}
}
```

このリンクをブラウザに貼り付けると自動的に有効化画面を開いてくれます

`https://console.developers.google.com/apis/api/books.googleapis.com/overview?project=-----`

キーを追加したリクエストは以下のようになります
```elixir
Req.get!(endpoint, params: [q: keyword, maxResults: 40, key: [your_api_key]])
```
成功すると以下のようなレスポンスが返ってきますので、必要なデータだけ抜き出すようにします

```elixir
%Req.Response{
  status: 200,
  headers: %{
    "alt-svc" => ["h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"],
    "cache-control" => ["private"],
    "content-length" => ["134964"],
    "content-type" => ["application/json; charset=UTF-8"],
    "date" => ["Tue, 12 Sep 2023 06:48:18 GMT"],
    "server" => ["ESF"],
    "transfer-encoding" => ["chunked"],
    "vary" => ["Origin", "X-Origin", "Referer"],
    "x-content-type-options" => ["nosniff"],
    "x-frame-options" => ["SAMEORIGIN"],
    "x-xss-protection" => ["0"]
  },
  body: %{
    "items" => [
      %{
        "accessInfo" => %{
          "accessViewStatus" => "NONE",
          "country" => "JP",
          "embeddable" => false,
          "epub" => %{"isAvailable" => false},
          "pdf" => %{"isAvailable" => false},
          "publicDomain" => false,
          "quoteSharingAllowed" => false,
          "textToSpeechPermission" => "ALLOWED",
          "viewability" => "NO_PAGES",
          "webReaderLink" => "http://play.google.com/books/reader?id=UMV0tAEACAAJ&hl=&source=gbs_api"
        },
        "etag" => "+Dyt8/61Gqo",
        "id" => "UMV0tAEACAAJ",
        "kind" => "books#volume",
        "saleInfo" => %{
          "country" => "JP",
          "isEbook" => false,
          "saleability" => "NOT_FOR_SALE"
        },
        "searchInfo" => %{
          "textSnippet" => "眠り鬼・魘夢にヒノカミ神楽「碧羅の天」を放った炭治郎の戦いの顛末は!? さらに、炭治郎一行の下に現れたものの正体とは!? ..."
        },
        "selfLink" => "https://www.googleapis.com/books/v1/volumes/UMV0tAEACAAJ",
        "volumeInfo" => %{
          "allowAnonLogging" => false,
          "authors" => ["吾峠呼世晴"],
          "canonicalVolumeLink" => "https://books.google.com/books/about/%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83_8.html?hl=&id=UMV0tAEACAAJ",
          "categories" => ["Brothers and sisters"],
          "contentVersion" => "preview-1.0.0",
          "description" => "In Taisho-era Japan, Tanjiro Kamado is a kindhearted boy who makes a living selling charcoal until his peaceful life is shattered when a demon slaughters his family and turns his sister into another demon, forcing Tanjiro on a dangerous journey to destroy the demon and save his sister.",
          "imageLinks" => %{
            "smallThumbnail" => "http://books.google.com/books/content?id=UMV0tAEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
            "thumbnail" => "http://books.google.com/books/content?id=UMV0tAEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
          },
          "industryIdentifiers" => [
            %{"identifier" => "4088812123", "type" => "ISBN_10"},
            %{"identifier" => "9784088812120", "type" => "ISBN_13"}
          ],
          "infoLink" => "http://books.google.co.jp/books?id=UMV0tAEACAAJ&dq=%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83&hl=&source=gbs_api",
          "language" => "ja",
          "maturityRating" => "NOT_MATURE",
          "pageCount" => 192,
          "panelizationSummary" => %{
            "containsEpubBubbles" => false,
            "containsImageBubbles" => false
          },
          "previewLink" => "http://books.google.co.jp/books?id=UMV0tAEACAAJ&dq=%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83&hl=&cd=1&source=gbs_api",
          "printType" => "BOOK",
          "publishedDate" => "2017-10",
          "readingModes" => %{"image" => false, "text" => false},
          "title" => "鬼滅の刃 8"
        }
      },
      ....
    ]
  }
}
```

タイトル、著者、ISBN、サムネイル、出版日を抜き出してMapを作成します

```diff_elixir:lib/ebookworm_web/live/search_live/index.ex
  def handle_event("search", %{"keyword" => keyword}, socket) do
    endpoint = "https://www.googleapis.com/books/v1/volumes"
    data =
      Req.get!(endpoint, params: [q: keyword, maxResults: 40])
+     |> Map.get(:body)
+     |> Map.get("items")
+     |> Enum.map(fn book ->
+       info = Map.get(book, "volumeInfo")
+
+       %{
+         id: book["id"],
+         title: info["title"],
+         authors: info["authors"],
+         isbn: info["industryIdentifiers"],
+         thumb: info["imageLinks"]["thumbnail"],
+         published_date: info["publishedDate"]
+       }
+     end)
-     |> IO.inspect()

    {:noreply, socket}
  end
```

データ不備があるものをEnum.filterで除外し、出版日でソートしてからstreamでsocketにアサインします
`reset: true`を入れないとデータの差し替えがされないようです

```diff_elixir
  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
    endpoint = "https://www.googleapis.com/books/v1/volumes"

    data =
      Req.get!(endpoint, params: [q: keyword, maxResults: 40])
      |> Map.get(:body)
      |> Map.get("items")
      |> Enum.map(fn book -> 
        ...
      end) 
+     |> Enum.filter(&(!is_nil(&1.thumb)))
+     |> Enum.filter(&(!is_nil(&1.authors)))
+     |> Enum.filter(&(!is_nil(&1.published_date)))
+     |> Enum.sort_by(& &1.published_date)

-    {:noreply, socket}
+    {:noreply, stream(socket, :books, data, reset: true)}
  end
```

# カードUI

取得したデータをアサインしたのでそのデータからカードUIを作成していきます

UIコンポーネントはこちらを参考にします

https://daisyui.com/components/card/#compact-card-less-padding-for-card-body

```diff_elixir:lib/ebookworm_web/live/search_live/index.ex
  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title="Search" />

    <div class="min-h-[120px]">
      <form id="book-form" class="flex w-full" phx-submit="search">
        <div class="w-3/4">
          <.input name="keyword" value="" type="text" />
        </div>
        <.button class="w-1/4 mt-2 ml-2 h-10" phx-disable-with="Searching...">Search</.button>
      </form>
+     <div
+       id="books_list"
+       class="flex flex-wrap gap-4 mt-4"
+       phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.books) && "stream"}
+     >
+     <%= for {id, book} <- @streams.books do %>
+       <div
+         id={id}
+         class="card bg-base-100 shadow-xl w-44 select-none"
+         phx-click={JS.push("new", value: %{book: book})}
+       >
+         <figure><img class="mt-1" src={book.thumb} /></figure>
+         <div class="p-2">
+           <p class="text-xs"><%= book.title %></p>
+           <p class="text-xs mt-2"><%= Enum.join(book.authors, ",") %></p>
+         </div>
+       </div>
+     <% end %>
+     </div>
    </div>
    <.bottom_tab title="Search" />
    """
  end
```

このコードはsteam関連のイベント(insert,delete)が発生したときに検知できるようにする設定です
`phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.books) && "stream"}`

カードをクリックしたら`new`イベントを発火させます

# 入力フォームの作成
`new`イベントが発火したときに表示するフォームを作成します

基本は先ほど作成した Bookのフォームをコピーして使用します　
先に呼び出し元の実装をします

```diff_elixir:lib/ebookworm_web/live/search_live/index.ex
defmodule EbookwormWeb.SearchLive.Index do
  use EbookwormWeb, :live_view

  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title="Search" />
    ...
    <.bottom_tab title="Search" />
    <!--- 以下追加 --->
    <.modal :if={@book} id="book-modal" show on_cancel={JS.patch(~p"/search")}>
      <.live_component
        module={EbookwormWeb.SearchLive.FormComponent}
        action={:new}
        title="New Book"
        id="book_form"
        book={@book}
        patch={~p"/search"}
      />
    </.modal>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Search Books")
+     |> assign(:book, nil)
     |> stream(:books, [])}
  end

+ @impl true
+ def handle_params(_unsigned_params, _uri, socket) do
+   {:noreply, socket}
+ end

  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
  ...
  end
  # 以下追加
  def handle_event("new", %{"book" => book_params}, socket) do
    book =
      %{
        book_id: book_params["id"],
        title: book_params["title"],
        author: Enum.join(book_params["authors"], ","),
        thumb: book_params["thumb"],
        isbn: book_params["isbn"] |> List.first() |> Map.get("identifier"),
        published_date: date_normalization(book_params["published_date"])
      }

    {:noreply, assign(socket, :book, book)}
  end

  @impl true
  def handle_info({EbookwormWeb.SearchLive.FormComponent, {:saved, book}}, socket) do
    socket
    |> stream_delete(:books, %{id: book["book_id"]})
    |> assign(:book, nil)
    |> then(&{:noreply, &1})
  end

  def date_normalization(date) do
    date
    |> String.split("-")
    |> length()
    |> case do
      2 -> date <> "-01"
      3 -> date
      _ -> nil
    end
  end
end
```

フォームのデータがある場合にモーダルを表示させます
モーダルを閉じるときに`handle_params`が無いとエラーになるので、特に何もしないコールバックを追加しています
`new`イベント発火時にデータをマップに作り直して、フォームのデータをアサインします
保存後にnotify_parentが実行され、保存した本の情報をbook_idをもとに削除

```diff_elixir:lib/ebookworm_web/live/search_live/form_component.ex
defmodule EbookwormWeb.SearchLive.FormComponent do
  use EbookwormWeb, :live_component

  alias Ebookworm.Books
+ alias Ebookworm.Books.Book 
+ alias Ebookworm.Shelves

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
-       <:subtitle>Use this form to manage book records in your database.</:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="book-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:author]} type="text" label="Author" />
-       <.input field={@form[:thumb]} type="text" label="Thumb" />
+       <.input field={@form[:thumb]} type="hidden" />
        <.input field={@form[:isbn]} type="text" label="Isbn" />
        <.input field={@form[:published_date]} type="date" label="Published date" />
+       <.input field={@form[:shelf_id]} type="select" label="Shelf" options={@shelf_options} />
+       <.input field={@form[:book_id]} type="hidden" />
        <:actions>
          <.button phx-disable-with="Saving...">Save Book</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  @impl true
  def update(%{book: book} = assigns, socket) do
-    changeset = Books.change_book(book)
+    changeset = Books.change_book(%Book{}, book)
+    # 親の本棚のリストを作成
+    options =
+      Shelves.list_shelves()
+      |> Enum.map(&{&1.name, &1.id})

    {:ok,
     socket
     |> assign(assigns)
     |> assign(:shelf_options, options)
     |> assign_form(changeset)}
  end

  @impl true
  def handle_event("validate", %{"book" => book_params}, socket) do
    changeset =
-     socket.assigns.book
+     %Book{}
      |> Books.change_book(book_params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  # コピーそのまま
  def handle_event("save", %{"book" => book_params}, socket) do
    save_book(socket, socket.assigns.action, book_params)
  end

-  defp save_book(socket, :edit, book_params) do
-    case Books.update_book(socket.assigns.book, book_params) do
-      {:ok, book} ->
-        notify_parent({:saved, book})
-
-        {:noreply,
-         socket
-         |> put_flash(:info, "Book updated successfully")
-         |> push_patch(to: socket.assigns.patch)}
-
-      {:error, %Ecto.Changeset{} = changeset} ->
-        {:noreply, assign_form(socket, changeset)}
-    end
-  end


  defp save_book(socket, :new, book_params) do
    case Books.create_book(book_params) do
-      {:ok, book} ->
-              notify_parent({:saved, book})
+      {:ok, _book} ->
+        # notifyするデータをはフォームのデータを使用する
+        notify_parent({:saved, book_params})

        {:noreply,
         socket
         |> put_flash(:info, "Book created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end
  # コピーそのまま
  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    assign(socket, :form, to_form(changeset))
  end
  # コピーそのまま
  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
```

`book_id`ですが、保存前のデータを参照するための仮想カラムです、スキーマで設定します

```diff_elixir:lib/ebookworm/books/book.ex
defmodule Ebookworm.Books.Book do
  use Ecto.Schema
  import Ecto.Changeset

  schema "books" do
    field :author, :string
    field :isbn, :string
    field :published_date, :date
    field :thumb, :string
    field :title, :string
+    field :book_id, :string, virtual: true

    belongs_to :shelf, Ebookworm.Shelves.Shelf

    timestamps()
  end

  @doc false
  def changeset(book, attrs) do
    book
-   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date, :shelf_id])
+   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date, :shelf_id, :book_id])
    |> validate_required([:title, :author, :thumb, :isbn, :published_date, :shelf_id])
  end
end
```

![0b9ba42a52f5349976ffa3098aeeaab5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/8282fb4e-49b9-ac56-b5cd-4e53f4d6f118.gif)

これでAPI通信から保存まで一通りできました

次は表示を行います

# ネストルーティング

保存できるようになったので、見れるようにしていきましょう
本棚に紐づいた本を一覧したいので、ネストさせたURLを作成します

```diff_elixir:lib/ebookworm_web/router.ex
  scope "/", EbookwormWeb do
    pipe_through :browser

    live "/shelves", ShelfLive.Index, :index
    live "/shelves/new", ShelfLive.Index, :new
    live "/shelves/:id/edit", ShelfLive.Index, :edit
    live "/shelves/:id/delete", ShelfLive.Index, :delete

-    live "/shelves/:id", ShelfLive.Show, :show
-    live "/shelves/:id/show/edit", ShelfLive.Show, :edit
+    live "/shelves/:shelf_id/books", BookLive.Index, :index
+    live "/shelves/:shelf_id/books/:id/edit", BookLive.Index, :edit
+    live "/shelves/:shelf_id/books/:id", BookLive.Show, :show
+    live "/shelves/:shelf_id/books/:id/show/edit", BookLive.Show, :edit
     live "/search", SearchLive.Index, :index
  end
```

本棚一覧のリンクを変更します

```diff_elixir:lib/ebookworm_web/live/shlef_live/index.html.heex
<.gheader title="Shelves">
  <:actions>
    <.link patch={~p"/shelves/new"}>
       <.icon name="hero-plus-solid" class="h-6 w-6 mr-4" /> 
    </.link>
  </:actions>
</.gheader>

<.table
  id="shelves"
  rows={@streams.shelves}
- row_click={fn {_id, shelf} -> JS.navigate(~p"/shelves/#{shelf}") end}
+ row_click={fn {_id, shelf} -> JS.navigate(~p"/shelves/#{shelf}/books") end}
>
  <:col :let={{_id, shelf}} label="Name"><%= shelf.name %></:col>
  <:action :let={{_id, shelf}}>
    <div class="sr-only">
-     <.link navigate={~p"/shelves/#{shelf}"}>Show</.link>
+     <.link navigate={~p"/shelves/#{shelf}/books"}>Show</.link>
    </div>
    <.link patch={~p"/shelves/#{shelf}/edit"}>Edit</.link>
  </:action>
  <:action :let={{_id, shelf}}>
    <.link
      patch={~p"/shelves/#{shelf}/delete"}
    >
      Delete
    </.link>
  </:action>
</.table>

<.bottom_tab title="Shelf" />
...
```

 本棚を取得するときに関連する本も取得するようにします

```diff_elixir
defmodule Ebookwormer.Shelves do
  ...
-  def get_shelf!(id), do: Repo.get!(Shelf,id)
+  def get_shelf!(id) do
+    from(s in Shelf, preload: :books)
+    |> Repo.get!(id)
+  end
  ...
end
```

index.html.heexを消して render関数内でビューを定義します
検索のを使いまわします

```heex:lib/ebookwormer_web/live/book_live/index.ex
defmodule EbookwormerWeb.BookLive.Index do
  use EbookwormerWeb, :live_view

  alias Ebookwormer.Shelves

  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title={@shelf.name}>
      <:back>
        <.link navigate={~p"/shelves"}>
          <.icon name="hero-chevron-left-solid" class="h-6 w-6" />
        </.link>
      </:back>
    </.gheader>

    <div
      id="books"
      class="flex flex-wrap gap-4 mt-4"
      phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.books) && "stream"}
    >
      <%= for {id, book} <- @streams.books do %>
        <.link
          id={id}
          class="card bg-base-100 shadow-xl w-44 select-none"
          navigate={~p"/shelves/#{book.shelf_id}/books/#{book}"}
        >
          <figure><img class="mt-1" src={book.thumb} /></figure>
          <div class="p-2">
            <p class="text-xs"><%= book.title %></p>
            <p class="text-xs mt-2"><%= book.author %></p>
          </div>
        </.link>
      <% end %>
    </div>
    <.bottom_tab title="Shelf" />
    """
  end

  @impl true
  def mount(%{"shelf_id" => shelf_id}, _session, socket) do
    shelf = Shelves.get_shelf!(shelf_id)

    socket
    |> assign(:shelf, shelf)
    |> stream(:books, shelf.books)
    |> then(&{:ok, &1})
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Books")
    |> assign(:book, nil)
  end
end
```

![スクリーンショット 2023-09-13 2.10.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/30be5fb2-b232-39a2-507d-c1a02922742a.png)


詳細画面では、リンクを差し替えて、サムネイルをimgタグで上の方に置きます
`/books/`なリンクを以下のように変更します
`/shelves/#{@book.shelf_id}/books/...`

```heex:lib/ebookwormer_web/live/book_live/show.html.heex
<.gheader title={@book.title}>
  <:back>
    <.link navigate={~p"/shelves/#{@book.shelf_id}/books"}>
      <.icon name="hero-chevron-left-solid" class="h-6 w-6" />
    </.link>
  </:back>
    <:actions>
    <.link patch={~p"/shelves/#{@book.shelf_id}/books/#{@book}/show/edit"} phx-click={JS.push_focus()}>
      <.icon name="hero-pencil-square-solid" class="h-6 w-6 mr-4" />
    </.link>
  </:actions>

</.gheader>
<figure><img class="mt-1" src={@book.thumb} /></figure>
<.list>
  <:item title="Title"><%= @book.title %></:item>
  <:item title="Author"><%= @book.author %></:item>
  <:item title="Isbn"><%= @book.isbn %></:item>
  <:item title="Published date"><%= @book.published_date %></:item>
</.list>

<.bottom_tab title="Shelf" />

<.modal :if={@live_action == :edit} id="book-modal" show on_cancel={JS.patch(~p"/shelves/#{@book.shelf_id}/books/#{@book}")}>
  <.live_component
    module={EbookwormerWeb.BookLive.FormComponent}
    id={@book.id}
    title={@page_title}
    action={@live_action}
    book={@book}
    patch={~p"/shelves/#{@book.shelf_id}/books/#{@book}"}
  />
</.modal>
```

![スクリーンショット 2023-09-13 2.10.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/54334899-308e-376a-8b07-faf0184670ef.png)

# フローティングアクションボタン(FAB)

最後にFABを実装します
FABは重要な機能に素早くアクセスさせるために使用されることが多いです


```lib/ebookworm_web/components/floating_action_button.ex
defmodule EbookwormWeb.FAB do
  use EbookwormWeb, :live_component

  @impl true
  def render(assigns) do
    ~H"""
    <div class="fixed z-90 bottom-20 right-8">
      <div class="flex flex-col">
        <%= if @open do %>
          <.menu />
        <% end %>
        <label class="btn btn-circle self-end">
          <div phx-click="toggle" phx-click-away="close" phx-target={@myself}>
            <%= if @open do %>
              <.icon name="hero-x-mark-solid" class="current-fill w-8 h-8" />
            <% else %>
              <.icon name="hero-plus-solid" class="current-fill w-8 h-8" />
            <% end %>
          </div>
        </label>
      </div>
    </div>
    """
  end

  def menu(assigns) do
    ~H"""
    <ul class="menu bg-base-100 w-42 p-2 rounded-box mb-4">
      <li>
        <.link navigate={~p"/search"}>
          <.icon name="hero-magnifying-glass-solid" class="h-8 w-8" /> Search Books
        </.link>
      </li>
      <li>
        <.link navigate={~p"/shelves/new"}>
          <.icon name="hero-book-open-solid" class="h-8 w-8" /> New Shelf
        </.link>
      </li>
    </ul>
    """
  end

  @impl true
  def update(_assigns, socket) do
    socket
    |> assign(:open, false)
    |> then(&{:ok, &1})
  end

  @impl true
  def handle_event("toggle", _, %{assigns: %{open: open}} = socket) do
    {:noreply, assign(socket, :open, !open)}
  end

  def handle_event("close", _, socket) do
    {:noreply, assign(socket, :open, false)}
  end
end
```

live_componentなので使用する時は以下のようになります

```heex:lib/ebookworm_web/live/shlef_live/index.html.heex
...
<.bottom_tab title="Shelf" />

<.live_component 
  id="fab"
  module={EbookwormerWeb.FAB}
/>
...
```

![スクリーンショット 2023-09-13 2.32.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/d72472bc-9a23-e6e8-a2b0-797b7d969560.png)
![スクリーンショット 2023-09-13 2.33.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/ce3749da-f7e9-c0b9-f044-2fce1350060c.png)

# 最後に

API通信部分が結構な実装の部分を締めてしまいましたが
ヘッダー、ボトムタブナビゲーション、フローティングアクションボタンがあると、スマホアプリ感が増したと思います。
今後はオンボーディング画面、設定画面を足していきたいと思います。

本記事は以上になりますありがとうございました