diff --git a/lib/simpleshop_theme/media.ex b/lib/simpleshop_theme/media.ex index 2b36c50..0cb5bbc 100644 --- a/lib/simpleshop_theme/media.ex +++ b/lib/simpleshop_theme/media.ex @@ -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 diff --git a/lib/simpleshop_theme/media/svg_recolorer.ex b/lib/simpleshop_theme/media/svg_recolorer.ex new file mode 100644 index 0000000..294018e --- /dev/null +++ b/lib/simpleshop_theme/media/svg_recolorer.ex @@ -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() + iex> SVGRecolorer.recolor(svg, "#ff6600") + ~s() + + """ + @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 diff --git a/lib/simpleshop_theme/settings/theme_settings.ex b/lib/simpleshop_theme/settings/theme_settings.ex index 8ae57d0..9191b0a 100644 --- a/lib/simpleshop_theme/settings/theme_settings.ex +++ b/lib/simpleshop_theme/settings/theme_settings.ex @@ -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) diff --git a/lib/simpleshop_theme/theme/presets.ex b/lib/simpleshop_theme/theme/presets.ex index 2ec249e..a5fb4f2 100644 --- a/lib/simpleshop_theme/theme/presets.ex +++ b/lib/simpleshop_theme/theme/presets.ex @@ -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 diff --git a/lib/simpleshop_theme_web/controllers/image_controller.ex b/lib/simpleshop_theme_web/controllers/image_controller.ex new file mode 100644 index 0000000..7c00102 --- /dev/null +++ b/lib/simpleshop_theme_web/controllers/image_controller.ex @@ -0,0 +1,93 @@ +defmodule SimpleshopThemeWeb.ImageController do + use SimpleshopThemeWeb, :controller + + alias SimpleshopTheme.Media + alias SimpleshopTheme.Media.SVGRecolorer + + @doc """ + Serves an image from the database by ID. + + Images are served with aggressive caching headers since they are + immutable once uploaded. + """ + def show(conn, %{"id" => id}) do + case Media.get_image(id) do + nil -> + 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", ~s("#{image.id}")) + |> send_resp(200, image.data) + end + end + + @doc """ + Serves a thumbnail of an image if available, otherwise falls back to full image. + """ + def thumbnail(conn, %{"id" => id}) do + case Media.get_image(id) do + nil -> + send_resp(conn, 404, "Image not found") + + %{thumbnail_data: thumbnail_data} = image when not is_nil(thumbnail_data) -> + conn + |> put_resp_content_type("image/jpeg") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", ~s("#{image.id}-thumb")) + |> send_resp(200, thumbnail_data) + + image -> + conn + |> put_resp_content_type(image.content_type) + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", ~s("#{image.id}")) + |> send_resp(200, image.data) + end + end + + @doc """ + Serves an SVG image recolored with the specified color. + + The color should be a hex color code (with or without the leading #). + Only works with SVG images. + """ + def recolored_svg(conn, %{"id" => id, "color" => color}) do + clean_color = normalize_color(color) + + with true <- SVGRecolorer.valid_hex_color?(clean_color), + %{is_svg: true, svg_content: svg} when not is_nil(svg) <- Media.get_image(id) do + recolored = SVGRecolorer.recolor(svg, clean_color) + + conn + |> put_resp_content_type("image/svg+xml") + |> put_resp_header("cache-control", "public, max-age=3600") + |> put_resp_header("etag", ~s("#{id}-#{clean_color}")) + |> send_resp(200, recolored) + else + false -> + send_resp(conn, 400, "Invalid color format") + + nil -> + send_resp(conn, 404, "Image not found") + + %{is_svg: false} -> + send_resp(conn, 400, "Image is not an SVG") + + %{svg_content: nil} -> + send_resp(conn, 400, "SVG content not available") + end + end + + defp normalize_color(color) do + color = String.trim(color) + + if String.starts_with?(color, "#") do + color + else + "#" <> color + end + end +end diff --git a/lib/simpleshop_theme_web/live/theme_live/index.ex b/lib/simpleshop_theme_web/live/theme_live/index.ex index b2d0839..f0f5d56 100644 --- a/lib/simpleshop_theme_web/live/theme_live/index.ex +++ b/lib/simpleshop_theme_web/live/theme_live/index.ex @@ -2,12 +2,14 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do use SimpleshopThemeWeb, :live_view alias SimpleshopTheme.Settings + alias SimpleshopTheme.Media alias SimpleshopTheme.Theme.{CSSGenerator, Presets, PreviewData} @impl true def mount(_params, _session, socket) do theme_settings = Settings.get_theme_settings() generated_css = CSSGenerator.generate(theme_settings) + active_preset = Presets.detect_preset(theme_settings) preview_data = %{ products: PreviewData.products(), cart_items: PreviewData.cart_items(), @@ -15,17 +17,86 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do categories: PreviewData.categories() } + logo_image = Media.get_logo() + header_image = Media.get_header() + socket = socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) |> assign(:preview_page, :home) - |> assign(:preset_names, Presets.list_names()) + |> assign(:presets_with_descriptions, Presets.all_with_descriptions()) + |> assign(:active_preset, active_preset) |> assign(:preview_data, preview_data) + |> assign(:logo_image, logo_image) + |> assign(:header_image, header_image) + |> assign(:customise_open, false) + |> allow_upload(:logo_upload, + accept: ~w(.png .jpg .jpeg .webp .svg), + max_entries: 1, + max_file_size: 2_000_000, + auto_upload: true, + progress: &handle_progress/3 + ) + |> allow_upload(:header_upload, + accept: ~w(.png .jpg .jpeg .webp), + max_entries: 1, + max_file_size: 5_000_000, + auto_upload: true, + progress: &handle_progress/3 + ) {:ok, socket} end + defp handle_progress(:logo_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry -> + case Media.upload_from_entry(path, entry, "logo") do + {:ok, image} -> + Settings.update_theme_settings(%{logo_image_id: image.id}) + {:ok, image} + + {:error, _} = error -> + error + end + end) + |> case do + [image | _] -> + {:noreply, assign(socket, :logo_image, image)} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + defp handle_progress(:header_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry -> + case Media.upload_from_entry(path, entry, "header") do + {:ok, image} -> + Settings.update_theme_settings(%{header_image_id: image.id}) + {:ok, image} + + {:error, _} = error -> + error + end + end) + |> case do + [image | _] -> + {:noreply, assign(socket, :header_image, image)} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + @impl true def handle_event("apply_preset", %{"preset" => preset_name}, socket) do preset_atom = String.to_existing_atom(preset_name) @@ -38,6 +109,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) + |> assign(:active_preset, preset_atom) |> put_flash(:info, "Applied #{preset_name} preset") {:noreply, socket} @@ -61,11 +133,13 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do case Settings.update_theme_settings(attrs) do {:ok, theme_settings} -> generated_css = CSSGenerator.generate(theme_settings) + active_preset = Presets.detect_preset(theme_settings) socket = socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) + |> assign(:active_preset, active_preset) {:noreply, socket} @@ -86,11 +160,13 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do case Settings.update_theme_settings(attrs) do {:ok, theme_settings} -> generated_css = CSSGenerator.generate(theme_settings) + active_preset = Presets.detect_preset(theme_settings) socket = socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) + |> assign(:active_preset, active_preset) {:noreply, socket} @@ -110,11 +186,13 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do case Settings.update_theme_settings(attrs) do {:ok, theme_settings} -> generated_css = CSSGenerator.generate(theme_settings) + active_preset = Presets.detect_preset(theme_settings) socket = socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) + |> assign(:active_preset, active_preset) {:noreply, socket} @@ -128,4 +206,57 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do socket = put_flash(socket, :info, "Theme saved successfully") {:noreply, socket} end + + @impl true + def handle_event("remove_logo", _params, socket) do + if logo = socket.assigns.logo_image do + Media.delete_image(logo) + end + + Settings.update_theme_settings(%{logo_image_id: nil}) + + socket = + socket + |> assign(:logo_image, nil) + |> put_flash(:info, "Logo removed") + + {:noreply, socket} + end + + @impl true + def handle_event("remove_header", _params, socket) do + if header = socket.assigns.header_image do + Media.delete_image(header) + end + + Settings.update_theme_settings(%{header_image_id: nil}) + + socket = + socket + |> assign(:header_image, nil) + |> put_flash(:info, "Header image removed") + + {:noreply, socket} + end + + @impl true + def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do + upload_atom = String.to_existing_atom(upload_name) + {:noreply, cancel_upload(socket, upload_atom, ref)} + end + + @impl true + def handle_event("toggle_customise", _params, socket) do + {:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)} + end + + @impl true + def handle_event("noop", _params, socket) do + {:noreply, socket} + end + + def error_to_string(:too_large), do: "File is too large" + def error_to_string(:too_many_files), do: "Too many files" + def error_to_string(:not_accepted), do: "File type not accepted" + def error_to_string(err), do: inspect(err) end diff --git a/lib/simpleshop_theme_web/live/theme_live/index.html.heex b/lib/simpleshop_theme_web/live/theme_live/index.html.heex index a592860..2664ef9 100644 --- a/lib/simpleshop_theme_web/live/theme_live/index.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/index.html.heex @@ -1,234 +1,611 @@
One theme, infinite possibilities. Every combination is designed to work beautifully.
+