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

Presets

-
- <%= for preset_name <- @preset_names do %> - - <% end %> -
-
+
+ +
+

Theme Studio

+

One theme, infinite possibilities. Every combination is designed to work beautifully.

+
- -
+ +
+ +
+ +
+
- -
-

Mood

-
- <%= for mood <- ["neutral", "warm", "cool", "dark"] do %> - - <% end %> -
-
+ +
+ - -
-

Typography

-
- + + + +
+
<%= title %>
+
<%= desc %>
+
+ + <% end %> +
+ + + <%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %> +
+ Upload logo (SVG or PNG) +
+ + + + <%= if @logo_image do %> +
+ Current logo + +
<% end %> - - -
- - -
-

Shape

-
- <%= for shape <- ["sharp", "soft", "round", "pill"] do %> - - <% end %> -
-
- - -
-

Density

-
- <%= for density <- ["spacious", "balanced", "compact"] do %> - - <% end %> -
-
- - -
-

Grid Columns

-
- <%= for cols <- ["2", "3", "4"] do %> - - <% end %> -
-
- - -
-

Accent Color

-
-
- -
-
-
- -
-

Header Layout

-
- <%= for layout <- ["standard", "centered", "minimal"] do %> - + <%= for entry <- @uploads.logo_upload.entries do %> +
+
+
+
+ <%= entry.progress %>% + +
+ <%= for err <- upload_errors(@uploads.logo_upload, entry) do %> +

<%= error_to_string(err) %>

+ <% end %> + <% end %> + + <%= for err <- upload_errors(@uploads.logo_upload) do %> +

<%= error_to_string(err) %>

+ <% end %> + + + <%= if @logo_image do %> +
+
+ Logo size + <%= @theme_settings.logo_size %>px +
+ +
+ + + <%= if @logo_image.is_svg do %> +
+ + + <%= if @theme_settings.logo_recolor do %> +
+ + <%= @theme_settings.logo_color %> +
+ <% end %> +
+ <% end %> <% end %>
+ <% end %> +
+ + +
+ +
+ + + <%= if @theme_settings.header_background_enabled do %> +
+ Upload header image +
+ +
+ + <%= if @header_image do %> +
+ Current header background + +
+ + +
+
+
+ Zoom + <%= @theme_settings.header_zoom %>% +
+ +
+
+
+ Horizontal position + <%= @theme_settings.header_position_x %>% +
+ +
+
+
+ Vertical position + <%= @theme_settings.header_position_y %>% +
+ +
+
+ <% end %> + + <%= for entry <- @uploads.header_upload.entries do %> +
+
+
+
+ <%= entry.progress %>% + +
+ <%= for err <- upload_errors(@uploads.header_upload, entry) do %> +

<%= error_to_string(err) %>

+ <% end %> + <% end %> + + <%= for err <- upload_errors(@uploads.header_upload) do %> +

<%= error_to_string(err) %>

+ <% end %>
+ <% end %> + + +
+ +
+ <%= for {preset_name, description} <- @presets_with_descriptions do %> + + <% end %> +
+
+ + +
+ +
+
+ + <%= @theme_settings.accent_color %> +
+
+
+ + +
+ + Customise + + + + + +
+ +
+
+ + + + + + Typography +
+ +
+ +
+ <%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %> + + <% end %> +
+
+
+ + +
+
+ + + + + Colours +
+ +
+ +
+ <%= for mood <- ["warm", "neutral", "cool", "dark"] do %> + + <% end %> +
+
+
+ + +
+
+ + + + + + Layout +
+ +
+ +
+ <%= for cols <- ["2", "3", "4"] do %> + + <% end %> +
+
+ +
+ +
+ <%= for density <- ["spacious", "balanced", "compact"] do %> + + <% end %> +
+
+ +
+ +
+ <%= for layout <- ["standard", "centered", "minimal"] do %> + + <% end %> +
+
+
+ + +
+
+ + + + Shape +
+ +
+ +
+ <%= for shape <- ["sharp", "soft", "round", "pill"] do %> + + <% end %> +
+
+
+
+
+ + +
+
Current combination
+
+ <%= 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) %> +
+
One of 100,000+ possible combinations
-
-
+
+
-
-
- <%= for {page_name, label} <- [ - {:home, "Home"}, - {:collection, "Collection"}, - {:pdp, "Product"}, - {:cart, "Cart"}, - {:about, "About"}, - {:contact, "Contact"}, - {:error, "404"} - ] do %> - - <% end %> +
+ <%= for {page_name, label} <- [ + {:home, "Home"}, + {:collection, "Collection"}, + {:pdp, "Product"}, + {:cart, "Cart"}, + {:about, "About"}, + {:contact, "Contact"}, + {:error, "404"} + ] do %> + + <% end %> +
+ + +
+
+
+
+
+
+
+ + + + + <%= @theme_settings.site_name |> String.downcase() |> String.replace(" ", "") %>.myshopify.com
-
+ data-header={@theme_settings.header_layout}> <%= case @preview_page do %> <% :home -> %> - + <% :collection -> %> - + <% :pdp -> %> - + <% :cart -> %> - + <% :about -> %> - + <% :contact -> %> - + <% :error -> %> - + <% end %>
diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages.ex b/lib/simpleshop_theme_web/live/theme_live/preview_pages.ex index a2af6b1..fe7f24f 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages.ex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages.ex @@ -2,4 +2,103 @@ defmodule SimpleshopThemeWeb.ThemeLive.PreviewPages do use Phoenix.Component embed_templates "preview_pages/*" + + @doc """ + Renders the shop header with logo based on logo_mode setting. + """ + attr :theme_settings, :map, required: true + attr :logo_image, :map, default: nil + attr :header_image, :map, default: nil + + def shop_header(assigns) do + ~H""" +
+ <%= if @theme_settings.header_background_enabled && @header_image do %> +
+ <% end %> + + <%= if @theme_settings.header_layout == "centered" do %> + + <% end %> + + + + <%= if @theme_settings.header_layout != "minimal" do %> + + <% end %> + +
+ +
+
+ """ + end + + defp header_justify("centered"), do: "justify-content: center; gap: 2rem;" + defp header_justify("minimal"), do: "justify-content: space-between;" + defp header_justify(_), do: "justify-content: space-between;" + + defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do + clean_color = String.trim_leading(color, "#") + "/images/#{logo_image.id}/recolored/#{clean_color}" + end + defp logo_url(logo_image, _), do: "/images/#{logo_image.id}" + + defp header_background_style(settings, header_image) do + "position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <> + "background-image: url('/images/#{header_image.id}'); " <> + "background-size: #{settings.header_zoom}%; " <> + "background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <> + "background-repeat: no-repeat; z-index: 0;" + end end diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/about.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/about.html.heex index 00d8ec8..01102b0 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/about.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/about.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+

diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/cart.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/cart.html.heex index a319780..114ce94 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/cart.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/cart.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+

diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/collection.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/collection.html.heex index f668d7c..12b5a1d 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/collection.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/collection.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+
diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/contact.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/contact.html.heex index bb18436..bc08f44 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/contact.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/contact.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+

diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/error.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/error.html.heex index 1c2f82f..472506d 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/error.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/error.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+
diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/home.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/home.html.heex index 13387c6..456e98f 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/home.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/home.html.heex @@ -1,21 +1,6 @@
-
- - -
- -
-
+
diff --git a/lib/simpleshop_theme_web/live/theme_live/preview_pages/pdp.html.heex b/lib/simpleshop_theme_web/live/theme_live/preview_pages/pdp.html.heex index 2693c7f..1278d67 100644 --- a/lib/simpleshop_theme_web/live/theme_live/preview_pages/pdp.html.heex +++ b/lib/simpleshop_theme_web/live/theme_live/preview_pages/pdp.html.heex @@ -3,22 +3,7 @@ %>
-
- - -
- -
-
+
diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index 77496d0..5333159 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -23,6 +23,15 @@ defmodule SimpleshopThemeWeb.Router do get "/", PageController, :home end + # Image serving routes (public, no auth required) + scope "/images", SimpleshopThemeWeb do + pipe_through :browser + + get "/:id", ImageController, :show + get "/:id/thumbnail", ImageController, :thumbnail + get "/:id/recolored/:color", ImageController, :recolored_svg + end + # Other scopes may use custom stacks. # scope "/api", SimpleshopThemeWeb do # pipe_through :api diff --git a/mix.exs b/mix.exs index 7e1ad23..dc32016 100644 --- a/mix.exs +++ b/mix.exs @@ -67,7 +67,8 @@ defmodule SimpleshopTheme.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, - {:tidewave, "~> 0.5", only: :dev} + {:tidewave, "~> 0.5", only: :dev}, + {:image, "~> 0.54"} ] end diff --git a/mix.lock b/mix.lock index c95fed8..8e5d9ef 100644 --- a/mix.lock +++ b/mix.lock @@ -21,6 +21,7 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "image": {:hex, :image, "0.62.1", "1dd3d8d0d29d6562aa2141b5ef08c0f6a60e2a9f843fe475499b2f4f1ef60406", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "5a5a7acaf68cfaed8932d478b95152cd7d84071442cac558c59f2d31427e91ab"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, @@ -38,6 +39,7 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, @@ -46,6 +48,7 @@ "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.3", "1378aefa93dbf887c2df60842be4cf312c57fdf99dbf91c5227cd4344050876e", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "605a1b912b7a8b56498077b3426be96b7129c4ac06d166311d408dccd0e5e0d3"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "vix": {:hex, :vix, "0.35.0", "f6319b715e3b072e53eba456a21af5f2ff010a7a7b19b884600ea98a0609b18c", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "a3e80067a89d0631b6cf2b93594e03c1b303a2c7cddbbdd28040750d521984e5"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } diff --git a/test/simpleshop_theme/media/svg_recolorer_test.exs b/test/simpleshop_theme/media/svg_recolorer_test.exs new file mode 100644 index 0000000..26a334b --- /dev/null +++ b/test/simpleshop_theme/media/svg_recolorer_test.exs @@ -0,0 +1,144 @@ +defmodule SimpleshopTheme.Media.SVGRecolorerTest do + use ExUnit.Case, async: true + + alias SimpleshopTheme.Media.SVGRecolorer + + describe "recolor/2" do + test "replaces fill attributes" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(fill="#ff6600") + refute result =~ "#000000" + end + + test "replaces stroke attributes" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(stroke="#ff6600") + refute result =~ "#000000" + end + + test "preserves fill=none" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(fill="none") + end + + test "preserves stroke=none" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(stroke="none") + end + + test "replaces currentColor" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "#ff6600" + refute result =~ "currentColor" + end + + test "handles multiple elements" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(fill="#ff6600") + refute result =~ "#000" + refute result =~ "#fff" + end + + test "handles RGB color names" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ ~s(fill="#ff6600") + refute result =~ "black" + end + + test "handles inline styles with fill" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "fill:#ff6600" + refute result =~ "#000000" + end + + test "handles both fill and stroke in same element" do + svg = ~s() + result = SVGRecolorer.recolor(svg, "#ff6600") + assert String.contains?(result, ~s(fill="#ff6600")) + assert String.contains?(result, ~s(stroke="#ff6600")) + end + + test "handles CSS style blocks with fill" do + svg = """ + + """ + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "fill:#ff6600" + refute result =~ "#FFFFFF" + refute result =~ "#EF1D1D" + end + + test "handles CSS style blocks with stroke" do + svg = """ + + """ + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "stroke:#ff6600" + refute result =~ "#000000" + end + + test "preserves fill:none in CSS" do + svg = """ + + """ + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "fill:none" + assert result =~ "stroke:#ff6600" + end + + test "handles CSS with color names" do + svg = """ + + """ + result = SVGRecolorer.recolor(svg, "#ff6600") + assert result =~ "fill:#ff6600" + assert result =~ "stroke:#ff6600" + refute result =~ "black" + refute result =~ "white" + end + end + + describe "valid_hex_color?/1" do + test "accepts 6-digit hex colors" do + assert SVGRecolorer.valid_hex_color?("#ff6600") + assert SVGRecolorer.valid_hex_color?("#FFFFFF") + assert SVGRecolorer.valid_hex_color?("#000000") + end + + test "accepts 3-digit hex colors" do + assert SVGRecolorer.valid_hex_color?("#f60") + assert SVGRecolorer.valid_hex_color?("#FFF") + assert SVGRecolorer.valid_hex_color?("#000") + end + + test "rejects invalid colors" do + refute SVGRecolorer.valid_hex_color?("ff6600") + refute SVGRecolorer.valid_hex_color?("#gg0000") + refute SVGRecolorer.valid_hex_color?("#ff") + refute SVGRecolorer.valid_hex_color?("red") + refute SVGRecolorer.valid_hex_color?(nil) + refute SVGRecolorer.valid_hex_color?(123) + end + end + + describe "normalize_hex_color/1" do + test "expands 3-digit hex to 6-digit" do + assert SVGRecolorer.normalize_hex_color("#f60") == "#ff6600" + assert SVGRecolorer.normalize_hex_color("#FFF") == "#FFFFFF" + assert SVGRecolorer.normalize_hex_color("#000") == "#000000" + end + + test "leaves 6-digit hex unchanged" do + assert SVGRecolorer.normalize_hex_color("#ff6600") == "#ff6600" + assert SVGRecolorer.normalize_hex_color("#FFFFFF") == "#FFFFFF" + end + end +end diff --git a/test/simpleshop_theme_web/controllers/image_controller_test.exs b/test/simpleshop_theme_web/controllers/image_controller_test.exs new file mode 100644 index 0000000..67af055 --- /dev/null +++ b/test/simpleshop_theme_web/controllers/image_controller_test.exs @@ -0,0 +1,124 @@ +defmodule SimpleshopThemeWeb.ImageControllerTest do + use SimpleshopThemeWeb.ConnCase + + alias SimpleshopTheme.Media + + @png_binary <<137, 80, 78, 71, 13, 10, 26, 10>> + @svg_content ~s() + + describe "show/2" do + test "returns 404 for non-existent image", %{conn: conn} do + conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}") + assert response(conn, 404) =~ "Image not found" + end + + test "serves image with proper content type and caching headers", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.png", + content_type: "image/png", + file_size: byte_size(@png_binary), + data: @png_binary + }) + + conn = get(conn, ~p"/images/#{image.id}") + + assert response(conn, 200) == @png_binary + assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"] + assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] + assert get_resp_header(conn, "etag") == [~s("#{image.id}")] + end + end + + describe "thumbnail/2" do + test "returns 404 for non-existent image", %{conn: conn} do + conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/thumbnail") + assert response(conn, 404) =~ "Image not found" + end + + test "falls back to full image when no thumbnail available", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.png", + content_type: "image/png", + file_size: byte_size(@png_binary), + data: @png_binary + }) + + conn = get(conn, ~p"/images/#{image.id}/thumbnail") + + assert response(conn, 200) == @png_binary + assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"] + end + end + + describe "recolored_svg/2" do + test "returns 404 for non-existent image", %{conn: conn} do + conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/recolored/ff6600") + assert response(conn, 404) =~ "Image not found" + end + + test "returns 400 for non-SVG image", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.png", + content_type: "image/png", + file_size: byte_size(@png_binary), + data: @png_binary + }) + + conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600") + assert response(conn, 400) =~ "not an SVG" + end + + test "returns 400 for invalid color", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.svg", + content_type: "image/svg+xml", + file_size: byte_size(@svg_content), + data: @svg_content + }) + + conn = get(conn, ~p"/images/#{image.id}/recolored/invalid") + assert response(conn, 400) =~ "Invalid color" + end + + test "recolors SVG with valid hex color", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.svg", + content_type: "image/svg+xml", + file_size: byte_size(@svg_content), + data: @svg_content + }) + + conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600") + + assert response(conn, 200) =~ ~s(fill="#ff6600") + assert get_resp_header(conn, "content-type") == ["image/svg+xml; charset=utf-8"] + assert get_resp_header(conn, "cache-control") == ["public, max-age=3600"] + end + + test "handles color with leading hash", %{conn: conn} do + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.svg", + content_type: "image/svg+xml", + file_size: byte_size(@svg_content), + data: @svg_content + }) + + # URL encodes # as %23 + conn = get(conn, "/images/#{image.id}/recolored/%23ff6600") + + assert response(conn, 200) =~ ~s(fill="#ff6600") + end + end +end