add header background contrast warning and improve branding UX
All checks were successful
deploy / deploy (push) Successful in 1m12s
All checks were successful
deploy / deploy (push) Successful in 1m12s
- extract dominant colors from header images during optimization - calculate WCAG contrast ratios against theme text color - show warning in theme editor when text may be hard to read - prevent hiding shop name when no logo is uploaded - auto-enable shop name when logo is deleted - fix image cache invalidation on delete - add missing .hidden utility class Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
32cc425458
commit
476da8121a
@ -4017,21 +4017,30 @@
|
|||||||
transition: width 0.2s;
|
transition: width 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small round remove button (overlaid on thumbnails) */
|
/* Round remove button (overlaid on thumbnails) */
|
||||||
.theme-remove-btn {
|
.theme-remove-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -0.375rem;
|
top: -0.5rem;
|
||||||
inset-inline-end: -0.375rem;
|
inset-inline-end: -0.5rem;
|
||||||
width: 1.125rem;
|
width: 1.75rem;
|
||||||
height: 1.125rem;
|
height: 1.75rem;
|
||||||
background: var(--t-text-primary);
|
background: var(--t-status-error);
|
||||||
color: var(--t-surface-base);
|
color: white;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 0.75rem;
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.15s ease, background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in oklch, var(--t-status-error) 85%, black);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thumbnail preview boxes */
|
/* Thumbnail preview boxes */
|
||||||
@ -4043,7 +4052,10 @@
|
|||||||
background: var(--t-surface-base);
|
background: var(--t-surface-base);
|
||||||
border: 1px solid var(--t-border-default);
|
border: 1px solid var(--t-border-default);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
}
|
||||||
|
|
||||||
|
.theme-thumb img {
|
||||||
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-thumb-logo {
|
.theme-thumb-logo {
|
||||||
@ -4342,6 +4354,35 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contrast warning for header backgrounds */
|
||||||
|
.theme-contrast-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: color-mix(in oklch, var(--t-status-warning) 12%, var(--t-surface-base));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
& .size-5 {
|
||||||
|
color: var(--t-status-warning);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Radio card selector (logo mode picker) */
|
/* Radio card selector (logo mode picker) */
|
||||||
.theme-radio-fieldset {
|
.theme-radio-fieldset {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@ -1045,6 +1045,7 @@
|
|||||||
/* ── Shop header ── */
|
/* ── Shop header ── */
|
||||||
|
|
||||||
.shop-header {
|
.shop-header {
|
||||||
|
position: relative;
|
||||||
background-color: var(--t-surface-raised);
|
background-color: var(--t-surface-raised);
|
||||||
border-bottom: 1px solid var(--t-border-default);
|
border-bottom: 1px solid var(--t-border-default);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -32,6 +32,10 @@
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide visually but keep in DOM (for phx-update="stream" empty states etc.) */
|
/* Hide visually but keep in DOM (for phx-update="stream" empty states etc.) */
|
||||||
.visually-hidden:not(:focus):not(:active) {
|
.visually-hidden:not(:focus):not(:active) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -113,12 +113,31 @@ defmodule Berrypod.Images.Optimizer do
|
|||||||
)
|
)
|
||||||
|> Stream.run()
|
|> Stream.run()
|
||||||
|
|
||||||
|
# Extract dominant colors for header images (used for contrast checking)
|
||||||
|
maybe_extract_dominant_colors(image, vips_image)
|
||||||
|
|
||||||
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
|
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
|
||||||
{:ok, widths}
|
{:ok, widths}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Extract and store dominant colors for header images.
|
||||||
|
# Used to calculate text contrast warnings in the theme editor.
|
||||||
|
defp maybe_extract_dominant_colors(%{image_type: "header"} = image, vips_image) do
|
||||||
|
case Image.dominant_color(vips_image, top_n: 5) do
|
||||||
|
{:ok, colors} when is_list(colors) ->
|
||||||
|
# Wrap single color in list if needed
|
||||||
|
colors = if is_list(hd(colors)), do: colors, else: [colors]
|
||||||
|
Repo.update!(ImageSchema.changeset(image, %{dominant_colors: Jason.encode!(colors)}))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_extract_dominant_colors(_image, _vips_image), do: :ok
|
||||||
|
|
||||||
defp generate_thumbnail(image, id) do
|
defp generate_thumbnail(image, id) do
|
||||||
path = Path.join(cache_dir(), "#{id}-thumb.jpg")
|
path = Path.join(cache_dir(), "#{id}-thumb.jpg")
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,7 @@ defmodule Berrypod.Media do
|
|||||||
|> Map.put(:source_width, width)
|
|> Map.put(:source_width, width)
|
||||||
|> Map.put(:source_height, height)
|
|> Map.put(:source_height, height)
|
||||||
|> Map.put(:variants_status, "pending")
|
|> Map.put(:variants_status, "pending")
|
||||||
|
|> maybe_extract_dominant_colors(webp_data)
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
# If conversion fails, store original image
|
# If conversion fails, store original image
|
||||||
@ -67,6 +68,26 @@ defmodule Berrypod.Media do
|
|||||||
|
|
||||||
defp prepare_image_attrs(attrs), do: attrs
|
defp prepare_image_attrs(attrs), do: attrs
|
||||||
|
|
||||||
|
# Extract dominant colors during upload for header images (used for contrast checking)
|
||||||
|
defp maybe_extract_dominant_colors(%{image_type: "header"} = attrs, webp_data) do
|
||||||
|
case Image.from_binary(webp_data) do
|
||||||
|
{:ok, vips_image} ->
|
||||||
|
case Image.dominant_color(vips_image, top_n: 5) do
|
||||||
|
{:ok, colors} when is_list(colors) ->
|
||||||
|
colors = if is_list(hd(colors)), do: colors, else: [colors]
|
||||||
|
Map.put(attrs, :dominant_colors, Jason.encode!(colors))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_extract_dominant_colors(attrs, _webp_data), do: attrs
|
||||||
|
|
||||||
defp is_svg?(content_type, filename) do
|
defp is_svg?(content_type, filename) do
|
||||||
content_type == "image/svg+xml" or
|
content_type == "image/svg+xml" or
|
||||||
String.ends_with?(filename || "", ".svg")
|
String.ends_with?(filename || "", ".svg")
|
||||||
@ -167,7 +188,7 @@ defmodule Berrypod.Media do
|
|||||||
where: i.image_type == "header",
|
where: i.image_type == "header",
|
||||||
order_by: [desc: i.inserted_at],
|
order_by: [desc: i.inserted_at],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
select: struct(i, [:id, :image_type, :source_width])
|
select: struct(i, [:id, :image_type, :source_width, :dominant_colors])
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsCache.put_cached(:header, header)
|
SettingsCache.put_cached(:header, header)
|
||||||
@ -185,14 +206,10 @@ defmodule Berrypod.Media do
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
def delete_image(%ImageSchema{} = image) do
|
def delete_image(%ImageSchema{} = image) do
|
||||||
result = Repo.delete(image)
|
# Invalidate cache before delete to ensure stale data isn't served
|
||||||
|
invalidate_media_cache(image.image_type)
|
||||||
|
|
||||||
case result do
|
Repo.delete(image)
|
||||||
{:ok, _} -> invalidate_media_cache(image.image_type)
|
|
||||||
_ -> :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@ -19,6 +19,7 @@ defmodule Berrypod.Media.Image do
|
|||||||
field :alt, :string
|
field :alt, :string
|
||||||
field :caption, :string
|
field :caption, :string
|
||||||
field :tags, :string
|
field :tags, :string
|
||||||
|
field :dominant_colors, :string
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
@ -41,7 +42,8 @@ defmodule Berrypod.Media.Image do
|
|||||||
:variants_status,
|
:variants_status,
|
||||||
:alt,
|
:alt,
|
||||||
:caption,
|
:caption,
|
||||||
:tags
|
:tags,
|
||||||
|
:dominant_colors
|
||||||
])
|
])
|
||||||
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
||||||
|> validate_inclusion(:image_type, ~w(logo header product icon media))
|
|> validate_inclusion(:image_type, ~w(logo header product icon media))
|
||||||
|
|||||||
@ -79,10 +79,37 @@ defmodule Berrypod.Settings do
|
|||||||
settings_map when is_map(settings_map) ->
|
settings_map when is_map(settings_map) ->
|
||||||
settings_map
|
settings_map
|
||||||
|> atomize_keys()
|
|> atomize_keys()
|
||||||
|
|> migrate_logo_mode()
|
||||||
|> then(&struct(ThemeSettings, &1))
|
|> then(&struct(ThemeSettings, &1))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Migrate old logo_mode field to new show_site_name/show_logo booleans
|
||||||
|
defp migrate_logo_mode(settings) do
|
||||||
|
case Map.get(settings, :logo_mode) do
|
||||||
|
"text-only" ->
|
||||||
|
settings
|
||||||
|
|> Map.put(:show_site_name, true)
|
||||||
|
|> Map.put(:show_logo, false)
|
||||||
|
|> Map.delete(:logo_mode)
|
||||||
|
|
||||||
|
"logo-text" ->
|
||||||
|
settings
|
||||||
|
|> Map.put(:show_site_name, true)
|
||||||
|
|> Map.put(:show_logo, true)
|
||||||
|
|> Map.delete(:logo_mode)
|
||||||
|
|
||||||
|
"logo-only" ->
|
||||||
|
settings
|
||||||
|
|> Map.put(:show_site_name, false)
|
||||||
|
|> Map.put(:show_logo, true)
|
||||||
|
|> Map.delete(:logo_mode)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
settings
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Returns the shop name from Settings, falling back to a default."
|
@doc "Returns the shop name from Settings, falling back to a default."
|
||||||
def site_name do
|
def site_name do
|
||||||
get_setting("site_name") || "Store Name"
|
get_setting("site_name") || "Store Name"
|
||||||
|
|||||||
@ -15,7 +15,8 @@ defmodule Berrypod.Settings.ThemeSettings do
|
|||||||
field :accent_color, :string, default: "#f97316"
|
field :accent_color, :string, default: "#f97316"
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
field :logo_mode, :string, default: "text-only"
|
field :show_site_name, :boolean, default: true
|
||||||
|
field :show_logo, :boolean, default: false
|
||||||
field :logo_image_id, :binary_id
|
field :logo_image_id, :binary_id
|
||||||
field :logo_size, :integer, default: 36
|
field :logo_size, :integer, default: 36
|
||||||
field :logo_recolor, :boolean, default: false
|
field :logo_recolor, :boolean, default: false
|
||||||
@ -66,7 +67,8 @@ defmodule Berrypod.Settings.ThemeSettings do
|
|||||||
:grid_columns,
|
:grid_columns,
|
||||||
:header_layout,
|
:header_layout,
|
||||||
:accent_color,
|
:accent_color,
|
||||||
:logo_mode,
|
:show_site_name,
|
||||||
|
:show_logo,
|
||||||
:logo_image_id,
|
:logo_image_id,
|
||||||
:logo_size,
|
:logo_size,
|
||||||
:logo_recolor,
|
:logo_recolor,
|
||||||
@ -107,7 +109,6 @@ defmodule Berrypod.Settings.ThemeSettings do
|
|||||||
|> validate_inclusion(:density, ~w(spacious balanced compact))
|
|> validate_inclusion(:density, ~w(spacious balanced compact))
|
||||||
|> validate_inclusion(:grid_columns, ~w(2 3 4))
|
|> validate_inclusion(:grid_columns, ~w(2 3 4))
|
||||||
|> validate_inclusion(:header_layout, ~w(standard centered left))
|
|> validate_inclusion(:header_layout, ~w(standard centered left))
|
||||||
|> validate_inclusion(:logo_mode, ~w(text-only logo-text logo-only))
|
|
||||||
|> validate_number(:logo_size, greater_than_or_equal_to: 24, less_than_or_equal_to: 120)
|
|> validate_number(:logo_size, greater_than_or_equal_to: 24, less_than_or_equal_to: 120)
|
||||||
|> validate_number(:header_zoom, greater_than_or_equal_to: 100, less_than_or_equal_to: 200)
|
|> validate_number(:header_zoom, greater_than_or_equal_to: 100, less_than_or_equal_to: 200)
|
||||||
|> validate_number(:header_position_x,
|
|> validate_number(:header_position_x,
|
||||||
|
|||||||
100
lib/berrypod/theme/contrast.ex
Normal file
100
lib/berrypod/theme/contrast.ex
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
defmodule Berrypod.Theme.Contrast do
|
||||||
|
@moduledoc """
|
||||||
|
WCAG contrast ratio calculations for accessibility checking.
|
||||||
|
|
||||||
|
Used to warn shop owners when header background images might make text hard to read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Calculate relative luminance from sRGB values (0-255).
|
||||||
|
Uses the WCAG 2.1 formula.
|
||||||
|
"""
|
||||||
|
def relative_luminance([r, g, b]) do
|
||||||
|
[r, g, b]
|
||||||
|
|> Enum.map(fn c ->
|
||||||
|
c = c / 255
|
||||||
|
|
||||||
|
if c <= 0.03928 do
|
||||||
|
c / 12.92
|
||||||
|
else
|
||||||
|
:math.pow((c + 0.055) / 1.055, 2.4)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> then(fn [r, g, b] -> 0.2126 * r + 0.7152 * g + 0.0722 * b end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Calculate WCAG contrast ratio between two colors.
|
||||||
|
Returns a ratio >= 1.0 (higher = better contrast).
|
||||||
|
"""
|
||||||
|
def contrast_ratio(color1, color2) do
|
||||||
|
l1 = relative_luminance(color1)
|
||||||
|
l2 = relative_luminance(color2)
|
||||||
|
{lighter, darker} = if l1 > l2, do: {l1, l2}, else: {l2, l1}
|
||||||
|
(lighter + 0.05) / (darker + 0.05)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Analyze header image colors against text color, return warning level.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- `:ok` - All dominant colors have acceptable contrast
|
||||||
|
- `:caution` - Some colors may be problematic
|
||||||
|
- `:poor` - Most colors have poor contrast
|
||||||
|
"""
|
||||||
|
def analyze_header_contrast(nil, _text_color), do: :ok
|
||||||
|
def analyze_header_contrast([], _text_color), do: :ok
|
||||||
|
|
||||||
|
def analyze_header_contrast(dominant_colors, text_color) when is_list(dominant_colors) do
|
||||||
|
text_rgb = hex_to_rgb(text_color)
|
||||||
|
|
||||||
|
ratios = Enum.map(dominant_colors, &contrast_ratio(&1, text_rgb))
|
||||||
|
min_ratio = Enum.min(ratios)
|
||||||
|
avg_ratio = Enum.sum(ratios) / length(ratios)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
# All colors have good contrast (3:1 is WCAG AA for large text)
|
||||||
|
Enum.all?(ratios, &(&1 >= 3.0)) -> :ok
|
||||||
|
# Most colors are problematic
|
||||||
|
avg_ratio < 3.0 -> :poor
|
||||||
|
# Some colors might be problematic
|
||||||
|
min_ratio < 2.0 -> :caution
|
||||||
|
true -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parse JSON-encoded dominant colors string.
|
||||||
|
"""
|
||||||
|
def parse_dominant_colors(nil), do: nil
|
||||||
|
def parse_dominant_colors(""), do: nil
|
||||||
|
|
||||||
|
def parse_dominant_colors(json) when is_binary(json) do
|
||||||
|
case Jason.decode(json) do
|
||||||
|
{:ok, colors} when is_list(colors) -> colors
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get the text color for a given mood setting.
|
||||||
|
"""
|
||||||
|
def text_color_for_mood("dark"), do: "#fafafa"
|
||||||
|
def text_color_for_mood("neutral"), do: "#171717"
|
||||||
|
def text_color_for_mood("warm"), do: "#1c1917"
|
||||||
|
def text_color_for_mood("cool"), do: "#0f172a"
|
||||||
|
def text_color_for_mood(_), do: "#171717"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Convert hex color to RGB list.
|
||||||
|
"""
|
||||||
|
def hex_to_rgb("#" <> hex), do: hex_to_rgb(hex)
|
||||||
|
|
||||||
|
def hex_to_rgb(hex) when byte_size(hex) == 6 do
|
||||||
|
[
|
||||||
|
String.slice(hex, 0..1) |> Integer.parse(16) |> elem(0),
|
||||||
|
String.slice(hex, 2..3) |> Integer.parse(16) |> elem(0),
|
||||||
|
String.slice(hex, 4..5) |> Integer.parse(16) |> elem(0)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -929,14 +929,21 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :logo_image, :map, default: nil
|
attr :logo_image, :map, default: nil
|
||||||
|
|
||||||
defp logo_inner(assigns) do
|
defp logo_inner(assigns) do
|
||||||
|
# Show logo if enabled and image exists
|
||||||
|
show_logo = assigns.theme_settings.show_logo && assigns.logo_image
|
||||||
|
|
||||||
|
# Show site name if enabled, or as fallback when logo should show but image is missing
|
||||||
|
show_site_name =
|
||||||
|
assigns.theme_settings.show_site_name ||
|
||||||
|
(assigns.theme_settings.show_logo && !assigns.logo_image)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:show_logo, show_logo)
|
||||||
|
|> assign(:show_site_name, show_site_name)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%= case @theme_settings.logo_mode do %>
|
<%= if @show_logo do %>
|
||||||
<% "text-only" -> %>
|
|
||||||
<span class="shop-logo-text">
|
|
||||||
{@site_name}
|
|
||||||
</span>
|
|
||||||
<% "logo-text" -> %>
|
|
||||||
<%= if @logo_image do %>
|
|
||||||
<img
|
<img
|
||||||
src={logo_url(@logo_image, @theme_settings)}
|
src={logo_url(@logo_image, @theme_settings)}
|
||||||
alt={@site_name}
|
alt={@site_name}
|
||||||
@ -944,23 +951,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
style={"height: #{@theme_settings.logo_size}px;"}
|
style={"height: #{@theme_settings.logo_size}px;"}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<span class="shop-logo-text">
|
<%= if @show_site_name do %>
|
||||||
{@site_name}
|
|
||||||
</span>
|
|
||||||
<% "logo-only" -> %>
|
|
||||||
<%= if @logo_image do %>
|
|
||||||
<img
|
|
||||||
src={logo_url(@logo_image, @theme_settings)}
|
|
||||||
alt={@site_name}
|
|
||||||
class="shop-logo-img"
|
|
||||||
style={"height: #{@theme_settings.logo_size}px;"}
|
|
||||||
/>
|
|
||||||
<% else %>
|
|
||||||
<span class="shop-logo-text">
|
|
||||||
{@site_name}
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
<% _ -> %>
|
|
||||||
<span class="shop-logo-text">
|
<span class="shop-logo-text">
|
||||||
{@site_name}
|
{@site_name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|
|
||||||
alias Berrypod.{Pages, Settings}
|
alias Berrypod.{Pages, Settings}
|
||||||
alias Berrypod.Media
|
alias Berrypod.Media
|
||||||
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
|
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets, PreviewData}
|
||||||
alias Berrypod.Workers.FaviconGeneratorWorker
|
alias Berrypod.Workers.FaviconGeneratorWorker
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -39,6 +39,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:customise_open, false)
|
|> assign(:customise_open, false)
|
||||||
|> assign(:sidebar_collapsed, false)
|
|> assign(:sidebar_collapsed, false)
|
||||||
|> assign(:cart_drawer_open, false)
|
|> assign(:cart_drawer_open, false)
|
||||||
|
|> compute_header_contrast_warning()
|
||||||
|> allow_upload(:logo_upload,
|
|> allow_upload(:logo_upload,
|
||||||
accept: ~w(.png .jpg .jpeg .webp .svg),
|
accept: ~w(.png .jpg .jpeg .webp .svg),
|
||||||
max_entries: 1,
|
max_entries: 1,
|
||||||
@ -112,7 +113,12 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
[image | _] ->
|
[image | _] ->
|
||||||
{:noreply, assign(socket, :header_image, image)}
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:header_image, image)
|
||||||
|
|> compute_header_contrast_warning()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -160,6 +166,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|> assign(:active_preset, preset_atom)
|
|> assign(:active_preset, preset_atom)
|
||||||
|
|> compute_header_contrast_warning()
|
||||||
|> put_flash(:info, "Applied #{preset_name} preset")
|
|> put_flash(:info, "Applied #{preset_name} preset")
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -206,6 +213,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|> assign(:active_preset, active_preset)
|
|> assign(:active_preset, active_preset)
|
||||||
|
|> maybe_recompute_contrast_warning(field)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
@ -246,6 +254,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|> assign(:active_preset, active_preset)
|
|> assign(:active_preset, active_preset)
|
||||||
|
|> maybe_recompute_contrast_warning(field)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
@ -284,7 +293,13 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
def handle_event("toggle_setting", %{"field" => field}, socket) do
|
def handle_event("toggle_setting", %{"field" => field}, socket) do
|
||||||
field_atom = String.to_existing_atom(field)
|
field_atom = String.to_existing_atom(field)
|
||||||
current_value = Map.get(socket.assigns.theme_settings, field_atom)
|
current_value = Map.get(socket.assigns.theme_settings, field_atom)
|
||||||
attrs = %{field_atom => !current_value}
|
new_value = !current_value
|
||||||
|
|
||||||
|
# Prevent turning off show_site_name when there's no logo to display
|
||||||
|
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
|
||||||
|
{:noreply, put_flash(socket, :error, "Upload a logo first to hide the shop name")}
|
||||||
|
else
|
||||||
|
attrs = %{field_atom => new_value}
|
||||||
|
|
||||||
case Settings.update_theme_settings(attrs) do
|
case Settings.update_theme_settings(attrs) do
|
||||||
{:ok, theme_settings} ->
|
{:ok, theme_settings} ->
|
||||||
@ -301,6 +316,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|> assign(:active_preset, active_preset)
|
|> assign(:active_preset, active_preset)
|
||||||
|
|> maybe_recompute_contrast_warning(field)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
@ -308,6 +324,11 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp has_valid_logo?(socket) do
|
||||||
|
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save_theme", _params, socket) do
|
def handle_event("save_theme", _params, socket) do
|
||||||
@ -351,11 +372,17 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
Media.delete_image(logo)
|
Media.delete_image(logo)
|
||||||
end
|
end
|
||||||
|
|
||||||
Settings.update_theme_settings(%{logo_image_id: nil})
|
# Re-enable shop name when removing logo to ensure header isn't empty
|
||||||
|
{:ok, theme_settings} =
|
||||||
|
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
|
||||||
|
|
||||||
|
generated_css = CSSGenerator.generate(theme_settings)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:logo_image, nil)
|
|> assign(:logo_image, nil)
|
||||||
|
|> assign(:theme_settings, theme_settings)
|
||||||
|
|> assign(:generated_css, generated_css)
|
||||||
|> put_flash(:info, "Logo removed")
|
|> put_flash(:info, "Logo removed")
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -372,6 +399,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:header_image, nil)
|
|> assign(:header_image, nil)
|
||||||
|
|> assign(:header_contrast_warning, :ok)
|
||||||
|> put_flash(:info, "Header image removed")
|
|> put_flash(:info, "Header image removed")
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -574,4 +602,29 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
urls -> urls
|
urls -> urls
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Compute header contrast warning based on image colors and theme mood
|
||||||
|
defp compute_header_contrast_warning(socket) do
|
||||||
|
header_image = socket.assigns.header_image
|
||||||
|
theme_settings = socket.assigns.theme_settings
|
||||||
|
|
||||||
|
warning =
|
||||||
|
if theme_settings.header_background_enabled && header_image do
|
||||||
|
text_color = Contrast.text_color_for_mood(theme_settings.mood)
|
||||||
|
colors = Contrast.parse_dominant_colors(header_image.dominant_colors)
|
||||||
|
Contrast.analyze_header_contrast(colors, text_color)
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :header_contrast_warning, warning)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only recompute when mood or header_background_enabled changes
|
||||||
|
defp maybe_recompute_contrast_warning(socket, field)
|
||||||
|
when field in ["mood", "header_background_enabled"] do
|
||||||
|
compute_header_contrast_warning(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_recompute_contrast_warning(socket, _field), do: socket
|
||||||
end
|
end
|
||||||
|
|||||||
@ -90,54 +90,34 @@
|
|||||||
|
|
||||||
<!-- Branding Section -->
|
<!-- Branding Section -->
|
||||||
<div class="theme-panel">
|
<div class="theme-panel">
|
||||||
<fieldset class="theme-radio-fieldset">
|
<span class="theme-section-label">Logo & header</span>
|
||||||
<legend class="theme-section-label">Logo & header</legend>
|
|
||||||
|
|
||||||
<div class="admin-stack admin-stack-sm theme-field">
|
<div class="admin-stack admin-stack-sm theme-field">
|
||||||
<%= for {value, title, desc} <- [
|
<label class="admin-toggle-label">
|
||||||
{"text-only", "Shop name only", "Your name in the heading font"},
|
|
||||||
{"logo-text", "Logo + shop name", "Your logo image with name beside it"},
|
|
||||||
{"logo-only", "Logo only", "Just your logo (with text built in)"}
|
|
||||||
] do %>
|
|
||||||
<label class={[
|
|
||||||
"theme-radio-card",
|
|
||||||
@theme_settings.logo_mode == value && "theme-radio-card-active"
|
|
||||||
]}>
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="checkbox"
|
||||||
id={"logo-mode-#{value}"}
|
checked={@theme_settings.show_site_name}
|
||||||
name="logo_mode"
|
phx-click="toggle_setting"
|
||||||
value={value}
|
phx-value-field="show_site_name"
|
||||||
checked={@theme_settings.logo_mode == value}
|
class="admin-toggle admin-toggle-sm"
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="logo_mode"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class="theme-radio-input"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span class="theme-slider-label">Show shop name</span>
|
||||||
class={[
|
|
||||||
"theme-radio-dot",
|
|
||||||
@theme_settings.logo_mode == value && "theme-radio-dot-active"
|
|
||||||
]}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span class={[
|
|
||||||
"theme-radio-dot-inner",
|
|
||||||
@theme_settings.logo_mode == value && "theme-radio-dot-inner-active"
|
|
||||||
]}>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="admin-fill">
|
|
||||||
<div class="theme-radio-title">{title}</div>
|
|
||||||
<div class="admin-text-secondary">{desc}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Logo Upload (for logo-text and logo-only modes) -->
|
<label class="admin-toggle-label">
|
||||||
<%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={@theme_settings.show_logo}
|
||||||
|
phx-click="toggle_setting"
|
||||||
|
phx-value-field="show_logo"
|
||||||
|
class="admin-toggle admin-toggle-sm"
|
||||||
|
/>
|
||||||
|
<span class="theme-slider-label">Show logo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logo Upload (when logo enabled) -->
|
||||||
|
<%= if @theme_settings.show_logo do %>
|
||||||
<div class="theme-subsection">
|
<div class="theme-subsection">
|
||||||
<span class="theme-slider-label theme-block-label">
|
<span class="theme-slider-label theme-block-label">
|
||||||
Upload logo (SVG or PNG)
|
Upload logo (SVG or PNG)
|
||||||
@ -153,7 +133,7 @@
|
|||||||
<div class="theme-thumb theme-thumb-logo">
|
<div class="theme-thumb theme-thumb-logo">
|
||||||
<img
|
<img
|
||||||
src={"/image_cache/#{@logo_image.id}.webp"}
|
src={"/image_cache/#{@logo_image.id}.webp"}
|
||||||
alt="Current logo"
|
alt={@site_name}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -164,29 +144,6 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
|
||||||
phx-change="update_image_alt"
|
|
||||||
phx-value-image-id={@logo_image.id}
|
|
||||||
class="theme-subfield-sm"
|
|
||||||
>
|
|
||||||
<label class="admin-row">
|
|
||||||
<span class="admin-text-secondary shrink-0">Alt text</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="alt"
|
|
||||||
value={@logo_image.alt || ""}
|
|
||||||
placeholder="Describe this image"
|
|
||||||
class="admin-input admin-input-sm admin-fill"
|
|
||||||
phx-debounce="blur"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p
|
|
||||||
:if={!@logo_image.alt || @logo_image.alt == ""}
|
|
||||||
class="theme-alt-warning"
|
|
||||||
>
|
|
||||||
Missing alt text — add a description for accessibility
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -372,10 +329,10 @@
|
|||||||
<!-- Short name -->
|
<!-- Short name -->
|
||||||
<div class="theme-subfield">
|
<div class="theme-subfield">
|
||||||
<form phx-change="update_setting" phx-value-field="favicon_short_name">
|
<form phx-change="update_setting" phx-value-field="favicon_short_name">
|
||||||
<div class="theme-slider-header">
|
<label class="theme-slider-label theme-block-label">
|
||||||
<span class="theme-slider-label">Short name</span>
|
Short name
|
||||||
<span class="admin-text-tertiary">Home screen label</span>
|
<span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="favicon_short_name"
|
name="favicon_short_name"
|
||||||
@ -448,7 +405,7 @@
|
|||||||
<div class="theme-thumb theme-thumb-cover theme-thumb-header">
|
<div class="theme-thumb theme-thumb-cover theme-thumb-header">
|
||||||
<img
|
<img
|
||||||
src={"/image_cache/#{@header_image.id}.webp"}
|
src={"/image_cache/#{@header_image.id}.webp"}
|
||||||
alt="Current header background"
|
alt=""
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -459,29 +416,31 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
|
||||||
phx-change="update_image_alt"
|
<%= if @header_contrast_warning != :ok do %>
|
||||||
phx-value-image-id={@header_image.id}
|
<div class="theme-contrast-warning">
|
||||||
class="theme-subfield-sm"
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
>
|
<div>
|
||||||
<label class="admin-row">
|
<strong>
|
||||||
<span class="admin-text-secondary shrink-0">Alt text</span>
|
<%= if @header_contrast_warning == :poor do %>
|
||||||
<input
|
Text may be hard to read
|
||||||
type="text"
|
<% else %>
|
||||||
name="alt"
|
Text contrast could be better
|
||||||
value={@header_image.alt || ""}
|
<% end %>
|
||||||
placeholder="Describe this image"
|
</strong>
|
||||||
class="admin-input admin-input-sm admin-fill"
|
<p>
|
||||||
phx-debounce="blur"
|
The header text might blend into this background.
|
||||||
/>
|
Try switching to a
|
||||||
</label>
|
<%= if @theme_settings.mood == "dark" do %>
|
||||||
<p
|
lighter mood
|
||||||
:if={!@header_image.alt || @header_image.alt == ""}
|
<% else %>
|
||||||
class="theme-alt-warning"
|
dark mood
|
||||||
>
|
<% end %>
|
||||||
Missing alt text — add a description for accessibility
|
or choosing a different image.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- Header Image Controls -->
|
<!-- Header Image Controls -->
|
||||||
<div class="admin-stack admin-stack-md theme-subfield">
|
<div class="admin-stack admin-stack-md theme-subfield">
|
||||||
@ -499,6 +458,7 @@
|
|||||||
class="admin-range"
|
class="admin-range"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
<%= if @theme_settings.header_zoom > 100 do %>
|
||||||
<form phx-change="update_setting" phx-value-field="header_position_x">
|
<form phx-change="update_setting" phx-value-field="header_position_x">
|
||||||
<div class="theme-slider-header">
|
<div class="theme-slider-header">
|
||||||
<span class="theme-slider-label">Horizontal position</span>
|
<span class="theme-slider-label">Horizontal position</span>
|
||||||
@ -527,6 +487,7 @@
|
|||||||
class="admin-range"
|
class="admin-range"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
@ -1117,23 +1078,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Current Combination Display -->
|
|
||||||
<div class="theme-combination">
|
|
||||||
<div class="theme-combination-label">Current combination</div>
|
|
||||||
<div class="theme-combination-value">
|
|
||||||
{String.capitalize(@theme_settings.mood)} · {String.capitalize(
|
|
||||||
@theme_settings.typography
|
|
||||||
)} · {String.capitalize(@theme_settings.shape)} · {String.capitalize(
|
|
||||||
@theme_settings.density
|
|
||||||
)} · {@theme_settings.grid_columns}-up · {String.capitalize(
|
|
||||||
@theme_settings.header_layout
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="theme-combination-footnote">
|
|
||||||
One of 100,000+ possible combinations
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddDominantColorsToImages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:images) do
|
||||||
|
add :dominant_colors, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user