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:
parent
994f6fe0d6
commit
44933acebb
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -11,6 +11,11 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
immutable once uploaded.
|
immutable once uploaded.
|
||||||
"""
|
"""
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
|
etag = ~s("#{id}")
|
||||||
|
|
||||||
|
if etag_match?(conn, etag) do
|
||||||
|
send_not_modified(conn, etag)
|
||||||
|
else
|
||||||
case Media.get_image(id) do
|
case Media.get_image(id) do
|
||||||
nil ->
|
nil ->
|
||||||
send_resp(conn, 404, "Image not found")
|
send_resp(conn, 404, "Image not found")
|
||||||
@ -19,10 +24,11 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
conn
|
conn
|
||||||
|> put_resp_content_type(image.content_type)
|
|> put_resp_content_type(image.content_type)
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{image.id}"))
|
|> put_resp_header("etag", etag)
|
||||||
|> send_resp(200, image.data)
|
|> send_resp(200, image.data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Serves a thumbnail of an image from the disk cache.
|
Serves a thumbnail of an image from the disk cache.
|
||||||
@ -32,17 +38,20 @@ 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
|
||||||
|
etag = ~s("#{id}-thumb")
|
||||||
|
|
||||||
|
if etag_match?(conn, etag) do
|
||||||
|
send_not_modified(conn, etag)
|
||||||
|
else
|
||||||
thumb_path = Media.get_thumbnail_path(id)
|
thumb_path = Media.get_thumbnail_path(id)
|
||||||
|
|
||||||
if File.exists?(thumb_path) do
|
if File.exists?(thumb_path) do
|
||||||
# Serve from disk cache
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("image/jpeg")
|
|> put_resp_content_type("image/jpeg")
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{id}-thumb"))
|
|> put_resp_header("etag", etag)
|
||||||
|> send_file(200, thumb_path)
|
|> send_file(200, thumb_path)
|
||||||
else
|
else
|
||||||
# Thumbnail not yet generated - generate on-demand
|
|
||||||
case Media.get_image(id) do
|
case Media.get_image(id) do
|
||||||
nil ->
|
nil ->
|
||||||
send_resp(conn, 404, "Image not found")
|
send_resp(conn, 404, "Image not found")
|
||||||
@ -53,11 +62,10 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
conn
|
conn
|
||||||
|> put_resp_content_type("image/jpeg")
|
|> put_resp_content_type("image/jpeg")
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{id}-thumb"))
|
|> put_resp_header("etag", etag)
|
||||||
|> send_resp(200, binary)
|
|> send_resp(200, binary)
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
# Fallback to full image if thumbnail generation fails
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("image/webp")
|
|> put_resp_content_type("image/webp")
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
@ -69,6 +77,7 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp generate_thumbnail_on_demand(image_data, thumb_path) do
|
defp generate_thumbnail_on_demand(image_data, thumb_path) do
|
||||||
with {:ok, image} <- Image.from_binary(image_data),
|
with {:ok, image} <- Image.from_binary(image_data),
|
||||||
@ -107,25 +116,25 @@ 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}")
|
||||||
|
|
||||||
|
if etag_match?(conn, etag) do
|
||||||
|
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
|
case Optimizer.generate_variant_on_demand(data, id, width, format) do
|
||||||
{:ok, path} ->
|
{:ok, path} ->
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type(format_content_type(format))
|
|> put_resp_content_type(format_content_type(format))
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{id}-#{width}.#{format}"))
|
|> put_resp_header("etag", etag)
|
||||||
|> send_file(200, path)
|
|> send_file(200, path)
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
send_resp(conn, 500, "Failed to generate variant")
|
send_resp(conn, 500, "Failed to generate variant")
|
||||||
end
|
end
|
||||||
else
|
|
||||||
:error ->
|
|
||||||
send_resp(conn, 400, "Invalid width or format")
|
|
||||||
|
|
||||||
false ->
|
|
||||||
send_resp(conn, 400, "Width not supported")
|
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
send_resp(conn, 404, "Image not found")
|
send_resp(conn, 404, "Image not found")
|
||||||
@ -134,6 +143,11 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
send_resp(conn, 404, "Image data not available")
|
send_resp(conn, 404, "Image data not available")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
:error -> send_resp(conn, 400, "Invalid width or format")
|
||||||
|
false -> send_resp(conn, 400, "Width not supported")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_width_and_format(width_with_ext) do
|
defp parse_width_and_format(width_with_ext) do
|
||||||
case String.split(width_with_ext, ".") do
|
case String.split(width_with_ext, ".") do
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user