fix search modal race condition and add 304 support for images

Route all search modal open/close through the JS hook via custom DOM
events so the _closing flag is always correctly managed. Prevents the
modal flashing back after Escape when a search response is in flight.

Add If-None-Match / 304 Not Modified handling to the image controller
so browsers don't re-download images on revalidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-13 16:21:51 +00:00
parent 994f6fe0d6
commit 44933acebb
3 changed files with 98 additions and 74 deletions

View File

@ -341,6 +341,9 @@ const SearchModal = {
} }
document.addEventListener("keydown", this._globalKeydown) document.addEventListener("keydown", this._globalKeydown)
this.el.addEventListener("open-search", () => this.open())
this.el.addEventListener("close-search", () => this.close())
this.el.addEventListener("keydown", (e) => { this.el.addEventListener("keydown", (e) => {
if (!this.isOpen()) return if (!this.isOpen()) return
@ -370,6 +373,7 @@ const SearchModal = {
}, },
updated() { updated() {
if (this._closing) this.el.style.display = "none"
this.selectedIndex = -1 this.selectedIndex = -1
this.updateHighlight() this.updateHighlight()
}, },
@ -379,6 +383,7 @@ const SearchModal = {
}, },
open() { open() {
this._closing = false
this.el.style.display = "flex" this.el.style.display = "flex"
this.pushEvent("open_search", {}) this.pushEvent("open_search", {})
const input = this.el.querySelector("#search-input") const input = this.el.querySelector("#search-input")
@ -391,6 +396,7 @@ const SearchModal = {
}, },
close() { close() {
this._closing = true
this.el.style.display = "none" this.el.style.display = "none"
this.pushEvent("clear_search", {}) this.pushEvent("clear_search", {})
this.selectedIndex = -1 this.selectedIndex = -1

View File

@ -383,10 +383,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
class="search-modal" class="search-modal"
style={"position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: #{if @search_open, do: "flex", else: "none"}; align-items: flex-start; justify-content: center; padding-top: 10vh;"} style={"position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: #{if @search_open, do: "flex", else: "none"}; align-items: flex-start; justify-content: center; padding-top: 10vh;"}
phx-hook="SearchModal" phx-hook="SearchModal"
phx-click={ phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
Phoenix.LiveView.JS.hide(to: "#search-modal")
|> Phoenix.LiveView.JS.push("clear_search")
}
> >
<div <div
class="search-modal-content w-full max-w-xl mx-4" class="search-modal-content w-full max-w-xl mx-4"
@ -437,10 +434,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
type="button" type="button"
class="w-8 h-8 flex items-center justify-center transition-all" class="w-8 h-8 flex items-center justify-center transition-all"
style="color: var(--t-text-tertiary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);" style="color: var(--t-text-tertiary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={ phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
Phoenix.LiveView.JS.hide(to: "#search-modal")
|> Phoenix.LiveView.JS.push("clear_search")
}
aria-label="Close search" aria-label="Close search"
> >
<svg <svg
@ -474,7 +468,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
style="text-decoration: none; color: inherit;" style="text-decoration: none; color: inherit;"
onmouseenter="this.style.background='var(--t-surface-sunken)'" onmouseenter="this.style.background='var(--t-surface-sunken)'"
onmouseleave="this.style.background='transparent'" onmouseleave="this.style.background='transparent'"
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")} phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
> >
<div <div
:if={item.image_url} :if={item.image_url}
@ -821,11 +815,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
type="button" type="button"
class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all" class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);" style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={ phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
Phoenix.LiveView.JS.push("open_search")
|> Phoenix.LiveView.JS.show(to: "#search-modal", display: "flex")
|> Phoenix.LiveView.JS.focus(to: "#search-input")
}
aria-label="Search" aria-label="Search"
> >
<svg <svg

View File

@ -11,16 +11,22 @@ defmodule SimpleshopThemeWeb.ImageController do
immutable once uploaded. immutable once uploaded.
""" """
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
case Media.get_image(id) do etag = ~s("#{id}")
nil ->
send_resp(conn, 404, "Image not found")
image -> if etag_match?(conn, etag) do
conn send_not_modified(conn, etag)
|> put_resp_content_type(image.content_type) else
|> put_resp_header("cache-control", "public, max-age=31536000, immutable") case Media.get_image(id) do
|> put_resp_header("etag", ~s("#{image.id}")) nil ->
|> send_resp(200, image.data) send_resp(conn, 404, "Image not found")
image ->
conn
|> put_resp_content_type(image.content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, image.data)
end
end end
end end
@ -32,40 +38,43 @@ defmodule SimpleshopThemeWeb.ImageController do
it on-demand and saves it for future requests. it on-demand and saves it for future requests.
""" """
def thumbnail(conn, %{"id" => id}) do def thumbnail(conn, %{"id" => id}) do
thumb_path = Media.get_thumbnail_path(id) etag = ~s("#{id}-thumb")
if File.exists?(thumb_path) do if etag_match?(conn, etag) do
# Serve from disk cache send_not_modified(conn, etag)
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{id}-thumb"))
|> send_file(200, thumb_path)
else else
# Thumbnail not yet generated - generate on-demand thumb_path = Media.get_thumbnail_path(id)
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
%{data: data} when is_binary(data) -> if File.exists?(thumb_path) do
case generate_thumbnail_on_demand(data, thumb_path) do conn
{:ok, binary} -> |> put_resp_content_type("image/jpeg")
conn |> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_content_type("image/jpeg") |> put_resp_header("etag", etag)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable") |> send_file(200, thumb_path)
|> put_resp_header("etag", ~s("#{id}-thumb")) else
|> send_resp(200, binary) case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
{:error, _} -> %{data: data} when is_binary(data) ->
# Fallback to full image if thumbnail generation fails case generate_thumbnail_on_demand(data, thumb_path) do
conn {:ok, binary} ->
|> put_resp_content_type("image/webp") conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable") |> put_resp_content_type("image/jpeg")
|> send_resp(200, data) |> put_resp_header("cache-control", "public, max-age=31536000, immutable")
end |> put_resp_header("etag", etag)
|> send_resp(200, binary)
%{data: nil} -> {:error, _} ->
send_resp(conn, 404, "Image data not available") conn
|> put_resp_content_type("image/webp")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, data)
end
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end end
end end
end end
@ -107,31 +116,36 @@ defmodule SimpleshopThemeWeb.ImageController do
alias SimpleshopTheme.Images.Optimizer alias SimpleshopTheme.Images.Optimizer
with {width, format} <- parse_width_and_format(width_with_ext), with {width, format} <- parse_width_and_format(width_with_ext),
true <- width in Optimizer.all_widths(), true <- width in Optimizer.all_widths() do
%{data: data} when is_binary(data) <- Media.get_image(id) do etag = ~s("#{id}-#{width}.#{format}")
case Optimizer.generate_variant_on_demand(data, id, width, format) do
{:ok, path} ->
conn
|> put_resp_content_type(format_content_type(format))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{id}-#{width}.#{format}"))
|> send_file(200, path)
{:error, _reason} -> if etag_match?(conn, etag) do
send_resp(conn, 500, "Failed to generate variant") send_not_modified(conn, etag)
else
case Media.get_image(id) do
%{data: data} when is_binary(data) ->
case Optimizer.generate_variant_on_demand(data, id, width, format) do
{:ok, path} ->
conn
|> put_resp_content_type(format_content_type(format))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, path)
{:error, _reason} ->
send_resp(conn, 500, "Failed to generate variant")
end
nil ->
send_resp(conn, 404, "Image not found")
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end end
else else
:error -> :error -> send_resp(conn, 400, "Invalid width or format")
send_resp(conn, 400, "Invalid width or format") false -> send_resp(conn, 400, "Width not supported")
false ->
send_resp(conn, 400, "Width not supported")
nil ->
send_resp(conn, 404, "Image not found")
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end end
end end
@ -194,4 +208,18 @@ defmodule SimpleshopThemeWeb.ImageController do
"#" <> color "#" <> color
end end
end end
defp etag_match?(conn, etag) do
case Plug.Conn.get_req_header(conn, "if-none-match") do
[^etag] -> true
_ -> false
end
end
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(304, "")
end
end end