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:
2025-12-31 18:55:44 +00:00
parent 0dada968aa
commit 1ca703e548
20 changed files with 1477 additions and 318 deletions

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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