feat: add dark mode support, accordion UI, and current combination display
- Update Theme Studio sidebar to use DaisyUI theme-aware classes for dark mode - Convert Customise accordion to native details/summary elements for proper interaction - Add "Current combination" card showing active theme settings - Add SVG recolorer for logo color customization - Add image controller for serving uploaded images - Implement header background image controls (zoom, position) - Add toggle_customise event handler to preserve accordion state across re-renders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,16 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias SimpleshopTheme.Repo
|
||||
alias SimpleshopTheme.Media.Image
|
||||
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
||||
|
||||
@thumbnail_size 200
|
||||
|
||||
@doc """
|
||||
Uploads an image and stores it in the database.
|
||||
|
||||
Automatically generates a thumbnail for non-SVG images if the Image library
|
||||
is available and working.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> upload_image(%{image_type: "logo", filename: "logo.png", ...})
|
||||
@@ -17,11 +22,66 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
"""
|
||||
def upload_image(attrs) do
|
||||
%Image{}
|
||||
|> Image.changeset(attrs)
|
||||
attrs = maybe_generate_thumbnail(attrs)
|
||||
|
||||
%ImageSchema{}
|
||||
|> ImageSchema.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Uploads an image from a LiveView upload entry.
|
||||
|
||||
This handles consuming the upload and extracting metadata from the entry.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> upload_from_entry(socket, :logo_upload, fn path, entry -> ... end)
|
||||
{:ok, %Image{}}
|
||||
|
||||
"""
|
||||
def upload_from_entry(path, entry, image_type) do
|
||||
file_binary = File.read!(path)
|
||||
|
||||
upload_image(%{
|
||||
image_type: image_type,
|
||||
filename: entry.client_name,
|
||||
content_type: entry.client_type,
|
||||
file_size: entry.client_size,
|
||||
data: file_binary
|
||||
})
|
||||
end
|
||||
|
||||
defp maybe_generate_thumbnail(%{data: data, content_type: content_type} = attrs)
|
||||
when is_binary(data) do
|
||||
if String.starts_with?(content_type || "", "image/svg") do
|
||||
attrs
|
||||
else
|
||||
case generate_thumbnail(data) do
|
||||
{:ok, thumbnail_data} ->
|
||||
Map.put(attrs, :thumbnail_data, thumbnail_data)
|
||||
|
||||
{:error, _reason} ->
|
||||
attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_generate_thumbnail(attrs), do: attrs
|
||||
|
||||
defp generate_thumbnail(image_data) do
|
||||
try do
|
||||
with {:ok, image} <- Image.from_binary(image_data),
|
||||
{:ok, thumbnail} <- Image.thumbnail(image, @thumbnail_size),
|
||||
{:ok, binary} <- Image.write(thumbnail, :memory, suffix: ".jpg") do
|
||||
{:ok, binary}
|
||||
end
|
||||
rescue
|
||||
_e ->
|
||||
{:error, :thumbnail_generation_failed}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single image by ID.
|
||||
|
||||
@@ -35,7 +95,7 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
"""
|
||||
def get_image(id) do
|
||||
Repo.get(Image, id)
|
||||
Repo.get(ImageSchema, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -48,7 +108,7 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
"""
|
||||
def get_logo do
|
||||
Repo.one(from i in Image, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1)
|
||||
Repo.one(from i in ImageSchema, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -61,7 +121,7 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
"""
|
||||
def get_header do
|
||||
Repo.one(from i in Image, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1)
|
||||
Repo.one(from i in ImageSchema, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -73,7 +133,7 @@ defmodule SimpleshopTheme.Media do
|
||||
{:ok, %Image{}}
|
||||
|
||||
"""
|
||||
def delete_image(%Image{} = image) do
|
||||
def delete_image(%ImageSchema{} = image) do
|
||||
Repo.delete(image)
|
||||
end
|
||||
|
||||
@@ -87,6 +147,6 @@ defmodule SimpleshopTheme.Media do
|
||||
|
||||
"""
|
||||
def list_images_by_type(type) do
|
||||
Repo.all(from i in Image, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
|
||||
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
|
||||
end
|
||||
end
|
||||
|
||||
134
lib/simpleshop_theme/media/svg_recolorer.ex
Normal file
134
lib/simpleshop_theme/media/svg_recolorer.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
defmodule SimpleshopTheme.Media.SVGRecolorer do
|
||||
@moduledoc """
|
||||
Recolors SVG images by replacing fill and stroke colors with a target color.
|
||||
|
||||
This module provides functionality to dynamically recolor SVG logos
|
||||
to match the site's branding colors.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Recolors an SVG by replacing common color attributes with the target color.
|
||||
|
||||
Replaces:
|
||||
- fill="..." attributes (except fill="none")
|
||||
- stroke="..." attributes (except stroke="none")
|
||||
- style="fill:..." inline styles
|
||||
- style="stroke:..." inline styles
|
||||
|
||||
## Examples
|
||||
|
||||
iex> svg = ~s(<svg><path fill="#000000" d="..."/></svg>)
|
||||
iex> SVGRecolorer.recolor(svg, "#ff6600")
|
||||
~s(<svg><path fill="#ff6600" d="..."/></svg>)
|
||||
|
||||
"""
|
||||
@spec recolor(String.t(), String.t()) :: String.t()
|
||||
def recolor(svg_content, target_color) when is_binary(svg_content) and is_binary(target_color) do
|
||||
svg_content
|
||||
|> recolor_fill_attributes(target_color)
|
||||
|> recolor_stroke_attributes(target_color)
|
||||
|> recolor_inline_fill_styles(target_color)
|
||||
|> recolor_inline_stroke_styles(target_color)
|
||||
|> recolor_css_fill_styles(target_color)
|
||||
|> recolor_css_stroke_styles(target_color)
|
||||
|> recolor_current_color(target_color)
|
||||
end
|
||||
|
||||
defp recolor_fill_attributes(svg, color) do
|
||||
Regex.replace(
|
||||
~r/fill\s*=\s*["'](?!none)([^"']+)["']/i,
|
||||
svg,
|
||||
~s(fill="#{color}")
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_stroke_attributes(svg, color) do
|
||||
Regex.replace(
|
||||
~r/stroke\s*=\s*["'](?!none)([^"']+)["']/i,
|
||||
svg,
|
||||
~s(stroke="#{color}")
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_inline_fill_styles(svg, color) do
|
||||
Regex.replace(
|
||||
~r/style\s*=\s*["']([^"']*)fill\s*:\s*(?!none)[^;}"']+([^"']*)["']/i,
|
||||
svg,
|
||||
~s(style="\\1fill:#{color}\\2")
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_inline_stroke_styles(svg, color) do
|
||||
Regex.replace(
|
||||
~r/style\s*=\s*["']([^"']*)stroke\s*:\s*(?!none)[^;}"']+([^"']*)["']/i,
|
||||
svg,
|
||||
~s(style="\\1stroke:#{color}\\2")
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_css_fill_styles(svg, color) do
|
||||
# Replace fill declarations in CSS style blocks: fill:#XXXXXX or fill: #XXXXXX
|
||||
# But preserve fill:none
|
||||
Regex.replace(
|
||||
~r/fill\s*:\s*(?!none)(#[0-9A-Fa-f]{3,6}|[a-zA-Z]+)(?=[;\s\}])/,
|
||||
svg,
|
||||
"fill:#{color}"
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_css_stroke_styles(svg, color) do
|
||||
# Replace stroke declarations in CSS style blocks: stroke:#XXXXXX or stroke: #XXXXXX
|
||||
# But preserve stroke:none
|
||||
Regex.replace(
|
||||
~r/stroke\s*:\s*(?!none)(#[0-9A-Fa-f]{3,6}|[a-zA-Z]+)(?=[;\s\}])/,
|
||||
svg,
|
||||
"stroke:#{color}"
|
||||
)
|
||||
end
|
||||
|
||||
defp recolor_current_color(svg, color) do
|
||||
String.replace(svg, "currentColor", color)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates that a string is a valid hex color code.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SVGRecolorer.valid_hex_color?("#ff6600")
|
||||
true
|
||||
|
||||
iex> SVGRecolorer.valid_hex_color?("#f60")
|
||||
true
|
||||
|
||||
iex> SVGRecolorer.valid_hex_color?("invalid")
|
||||
false
|
||||
|
||||
"""
|
||||
@spec valid_hex_color?(String.t()) :: boolean()
|
||||
def valid_hex_color?(color) when is_binary(color) do
|
||||
Regex.match?(~r/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, color)
|
||||
end
|
||||
|
||||
def valid_hex_color?(_), do: false
|
||||
|
||||
@doc """
|
||||
Normalizes a hex color to 6-digit format.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SVGRecolorer.normalize_hex_color("#f60")
|
||||
"#ff6600"
|
||||
|
||||
iex> SVGRecolorer.normalize_hex_color("#ff6600")
|
||||
"#ff6600"
|
||||
|
||||
"""
|
||||
@spec normalize_hex_color(String.t()) :: String.t()
|
||||
def normalize_hex_color("#" <> hex) when byte_size(hex) == 3 do
|
||||
[r, g, b] = String.graphemes(hex)
|
||||
"##{r}#{r}#{g}#{g}#{b}#{b}"
|
||||
end
|
||||
|
||||
def normalize_hex_color(color), do: color
|
||||
end
|
||||
@@ -15,12 +15,16 @@ defmodule SimpleshopTheme.Settings.ThemeSettings do
|
||||
field :accent_color, :string, default: "#f97316"
|
||||
|
||||
# Branding
|
||||
field :site_name, :string, default: "Store Name"
|
||||
field :logo_mode, :string, default: "text-only"
|
||||
field :logo_image_id, :binary_id
|
||||
field :header_image_id, :binary_id
|
||||
field :logo_size, :integer, default: 36
|
||||
field :logo_recolor, :boolean, default: false
|
||||
field :logo_color, :string, default: "#171717"
|
||||
|
||||
# Header Background
|
||||
field :header_background_enabled, :boolean, default: false
|
||||
field :header_image_id, :binary_id
|
||||
field :header_zoom, :integer, default: 100
|
||||
field :header_position_x, :integer, default: 50
|
||||
field :header_position_y, :integer, default: 50
|
||||
@@ -58,12 +62,14 @@ defmodule SimpleshopTheme.Settings.ThemeSettings do
|
||||
:grid_columns,
|
||||
:header_layout,
|
||||
:accent_color,
|
||||
:site_name,
|
||||
:logo_mode,
|
||||
:logo_image_id,
|
||||
:header_image_id,
|
||||
:logo_size,
|
||||
:logo_recolor,
|
||||
:logo_color,
|
||||
:header_background_enabled,
|
||||
:header_image_id,
|
||||
:header_zoom,
|
||||
:header_position_x,
|
||||
:header_position_y,
|
||||
@@ -92,7 +98,7 @@ defmodule SimpleshopTheme.Settings.ThemeSettings do
|
||||
|> validate_inclusion(:density, ~w(spacious balanced compact))
|
||||
|> validate_inclusion(:grid_columns, ~w(2 3 4))
|
||||
|> validate_inclusion(:header_layout, ~w(standard centered minimal))
|
||||
|> validate_inclusion(:logo_mode, ~w(text-only logo-text logo-only header-image logo-header))
|
||||
|> 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(:header_zoom, greater_than_or_equal_to: 100, less_than_or_equal_to: 200)
|
||||
|> validate_number(:header_position_x, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|
||||
|
||||
@@ -93,6 +93,21 @@ defmodule SimpleshopTheme.Theme.Presets do
|
||||
}
|
||||
}
|
||||
|
||||
@descriptions %{
|
||||
gallery: "Elegant & editorial",
|
||||
studio: "Clean & professional",
|
||||
boutique: "Warm & sophisticated",
|
||||
bold: "High contrast, strong",
|
||||
playful: "Fun & approachable",
|
||||
minimal: "Understated & modern",
|
||||
night: "Dark & dramatic",
|
||||
classic: "Traditional & refined",
|
||||
impulse: "Light & airy"
|
||||
}
|
||||
|
||||
# Core keys used to match presets (excludes branding-specific settings)
|
||||
@core_keys ~w(mood typography shape density grid_columns header_layout accent_color)a
|
||||
|
||||
@doc """
|
||||
Returns all available presets.
|
||||
|
||||
@@ -132,4 +147,72 @@ defmodule SimpleshopTheme.Theme.Presets do
|
||||
def list_names do
|
||||
Map.keys(@presets)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the description for a preset.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_description(:gallery)
|
||||
"Elegant & editorial"
|
||||
|
||||
"""
|
||||
def get_description(preset_name) when is_atom(preset_name) do
|
||||
Map.get(@descriptions, preset_name, "")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all presets with their descriptions.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> all_with_descriptions()
|
||||
[{:bold, "High contrast, strong"}, ...]
|
||||
|
||||
"""
|
||||
def all_with_descriptions do
|
||||
@presets
|
||||
|> Map.keys()
|
||||
|> Enum.sort()
|
||||
|> Enum.map(fn name -> {name, Map.get(@descriptions, name, "")} end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Detects which preset matches the current theme settings, if any.
|
||||
Only compares core theme keys, ignoring branding-specific settings.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> detect_preset(%ThemeSettings{mood: "warm", typography: "editorial", ...})
|
||||
:gallery
|
||||
|
||||
iex> detect_preset(%ThemeSettings{...customized...})
|
||||
nil
|
||||
|
||||
"""
|
||||
def detect_preset(theme_settings) do
|
||||
current_core = extract_core_values(theme_settings)
|
||||
|
||||
Enum.find_value(@presets, fn {name, preset} ->
|
||||
preset_core = Map.take(preset, @core_keys)
|
||||
|
||||
if maps_match?(current_core, preset_core) do
|
||||
name
|
||||
else
|
||||
nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_core_values(theme_settings) do
|
||||
theme_settings
|
||||
|> Map.from_struct()
|
||||
|> Map.take(@core_keys)
|
||||
end
|
||||
|
||||
defp maps_match?(map1, map2) do
|
||||
Enum.all?(@core_keys, fn key ->
|
||||
Map.get(map1, key) == Map.get(map2, key)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user